Heartbeat-ul periodic (activ/IDLE la 30 min) e oprit implicit (heartbeat_min = 0). Semnalul „alive" devin alertele zilnice „🟢 Piața deschisă" / „🔴 Piața închisă", emise pe tranziția ferestrei (operating_hours AND /window) independent de pauză — body-ul spune dacă monitorizarea e pauzată manual sau pe drift. /window era doar în memorie și se pierdea la repornire (09-09), așa că programul revenea la 16:30–23:00 și trimitea mesaje înainte/după fereastra 19:30–22:00. Acum e persistat în logs/session_window.json și restaurat la pornire; /window off șterge fișierul. /status afișează blocul de fereastră: deschisă/închisă + următoarea deschidere/închidere (ora locală), /window, orele bursei cu echivalentul local, și config | heartbeat. Config activ: heartbeat_min = 0; include și baseline_phash rescris de auto-rebase-ul canary din 09-09. DOX pass: notifier + configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Em4jtmh4pRpYMdyd9ueHYF
2679 lines
96 KiB
Python
2679 lines
96 KiB
Python
"""Tests for atm.main unified CLI."""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import types
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock
|
||
|
||
import cv2
|
||
import numpy as np
|
||
import pytest
|
||
|
||
|
||
SUBCOMMANDS = ["calibrate", "label", "dryrun", "run", "journal", "report"]
|
||
|
||
# Ensure subprocess invocations find the atm package even without pip install
|
||
_SRC = str(Path(__file__).resolve().parent.parent / "src")
|
||
_SUBPROCESS_ENV = {**os.environ, "PYTHONPATH": _SRC}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _mock_config_class(cfg=None):
|
||
"""Return a Config-like class whose load_current() returns *cfg*."""
|
||
if cfg is None:
|
||
cfg = MagicMock()
|
||
# window_title must be a real falsy value, not a MagicMock auto-attribute;
|
||
# otherwise _cmd_run enters _focus_window_by_title and TypeErrors.
|
||
cfg.window_title = None
|
||
mock_cls = MagicMock()
|
||
mock_cls.load_current.return_value = cfg
|
||
return mock_cls
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test_help_works
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_help_works():
|
||
result = subprocess.run(
|
||
[sys.executable, "-m", "atm", "--help"],
|
||
capture_output=True,
|
||
text=True,
|
||
env=_SUBPROCESS_ENV,
|
||
)
|
||
assert result.returncode == 0, result.stderr
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test_subcommands_listed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_subcommands_listed():
|
||
result = subprocess.run(
|
||
[sys.executable, "-m", "atm", "--help"],
|
||
capture_output=True,
|
||
text=True,
|
||
env=_SUBPROCESS_ENV,
|
||
)
|
||
output = result.stdout
|
||
for cmd in SUBCOMMANDS:
|
||
assert cmd in output, f"Expected subcommand '{cmd}' in --help output"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test_dryrun_wiring
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class _DryrunResult:
|
||
acceptance_pass: bool
|
||
|
||
|
||
def _make_dryrun_module(acceptance_pass: bool):
|
||
mod = types.ModuleType("atm.dryrun")
|
||
mod.dryrun = lambda *a, **kw: _DryrunResult(acceptance_pass=acceptance_pass)
|
||
mod.print_report = lambda r: None
|
||
return mod
|
||
|
||
|
||
def test_dryrun_wiring_pass(monkeypatch, tmp_path):
|
||
import atm.main as _main
|
||
|
||
monkeypatch.setattr("atm.main.dryrun", _make_dryrun_module(acceptance_pass=True))
|
||
monkeypatch.setattr("atm.main.Config", _mock_config_class())
|
||
|
||
with pytest.raises(SystemExit) as exc_info:
|
||
_main.main(["dryrun", str(tmp_path)])
|
||
|
||
assert exc_info.value.code == 0
|
||
|
||
|
||
def test_dryrun_wiring_fail(monkeypatch, tmp_path):
|
||
import atm.main as _main
|
||
|
||
monkeypatch.setattr("atm.main.dryrun", _make_dryrun_module(acceptance_pass=False))
|
||
monkeypatch.setattr("atm.main.Config", _mock_config_class())
|
||
|
||
with pytest.raises(SystemExit) as exc_info:
|
||
_main.main(["dryrun", str(tmp_path)])
|
||
|
||
assert exc_info.value.code == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test_report_current_week_default
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_report_current_week_default(monkeypatch, tmp_path):
|
||
import atm.main as _main
|
||
|
||
# Journal.all returns no entries — report should print a zero-trade week
|
||
monkeypatch.setattr("atm.journal.Journal.all", lambda self: [])
|
||
|
||
# Should not raise; no sys.exit expected
|
||
_main.main(["report", "--file", str(tmp_path / "trades.jsonl")])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# test_run_live_dry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_run_live_dry(monkeypatch):
|
||
import atm.main as _main
|
||
|
||
calls: list[dict] = []
|
||
|
||
def _mock_run_live(cfg, duration_s=None, capture_stub=False):
|
||
calls.append({"cfg": cfg, "duration_s": duration_s, "capture_stub": capture_stub})
|
||
|
||
monkeypatch.setattr("atm.main.run_live", _mock_run_live)
|
||
monkeypatch.setattr("atm.main.Config", _mock_config_class())
|
||
|
||
_main.main(["run", "--duration", "0"])
|
||
|
||
assert len(calls) == 1
|
||
assert calls[0]["duration_s"] == pytest.approx(0.0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Regression integration test — user bug 2026-04-16.
|
||
# Session starts with an accepted gray tick followed by dark_red. Catchup
|
||
# synth-arm must fire on dark_red (the previous gray consumed first_accepted),
|
||
# then light_red triggers SELL. Proves run_live wiring dispatches alerts for
|
||
# the user's exact scenario.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_run_live_catchup_sell_from_gray_then_dark_red(monkeypatch, tmp_path):
|
||
import numpy as np
|
||
import atm.main as _main
|
||
from atm.detector import DetectionResult
|
||
|
||
captured = []
|
||
|
||
class FakeFanout:
|
||
def __init__(self, *a, **kw):
|
||
pass
|
||
def send(self, alert):
|
||
captured.append(alert)
|
||
def stop(self):
|
||
pass
|
||
|
||
class FakeCanaryResult:
|
||
distance = 0
|
||
drifted = False
|
||
paused = False
|
||
|
||
class FakeCanary:
|
||
def __init__(self, *a, **kw):
|
||
self.is_paused = False
|
||
def check(self, frame):
|
||
return FakeCanaryResult()
|
||
def resume(self):
|
||
pass
|
||
|
||
class _StopLoop(Exception):
|
||
pass
|
||
|
||
class ScriptedDetector:
|
||
_script = [
|
||
("gray", True),
|
||
("gray", True),
|
||
("dark_red", True),
|
||
("dark_red", True),
|
||
("light_red", True),
|
||
]
|
||
def __init__(self, *a, **kw):
|
||
self._i = 0
|
||
def step(self, ts, frame=None):
|
||
if self._i >= len(self._script):
|
||
raise _StopLoop
|
||
color, accepted = self._script[self._i]
|
||
self._i += 1
|
||
return DetectionResult(
|
||
ts=ts,
|
||
window_found=True,
|
||
dot_found=True,
|
||
rgb=(1, 1, 1),
|
||
match=None,
|
||
accepted=accepted,
|
||
color=color,
|
||
)
|
||
|
||
def fake_build_capture(cfg, capture_stub=False):
|
||
return lambda: np.zeros((50, 50, 3), dtype=np.uint8)
|
||
|
||
cfg = MagicMock()
|
||
cfg.lockout_s = 60
|
||
cfg.heartbeat_min = 999
|
||
cfg.loop_interval_s = 0
|
||
cfg.config_version = "test"
|
||
cfg.dead_letter_path = str(tmp_path / "dl.jsonl")
|
||
cfg.canary.drift_threshold = 10
|
||
cfg.dot_roi.x = 0
|
||
cfg.dot_roi.y = 0
|
||
cfg.dot_roi.w = 10
|
||
cfg.dot_roi.h = 10
|
||
cfg.chart_window_region = None
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
class _Stub:
|
||
def __init__(self, *a, **kw):
|
||
pass
|
||
def log(self, *a, **kw):
|
||
pass
|
||
def close(self, *a, **kw):
|
||
pass
|
||
def step(self, *a, **kw):
|
||
return types.SimpleNamespace(status="pending", levels=None)
|
||
|
||
class _StubPoller:
|
||
def __init__(self, *a, **kw): pass
|
||
async def run(self): await asyncio.sleep(9999)
|
||
|
||
class _StubScheduler:
|
||
def __init__(self, *a, **kw):
|
||
self.is_running = False
|
||
def start(self, interval_s): self.is_running = True
|
||
def stop(self): self.is_running = False
|
||
async def run(self): await asyncio.sleep(9999)
|
||
|
||
monkeypatch.setattr("atm.detector.Detector", ScriptedDetector)
|
||
monkeypatch.setattr("atm.canary.Canary", FakeCanary)
|
||
monkeypatch.setattr("atm.notifier.fanout.FanoutNotifier", FakeFanout)
|
||
monkeypatch.setattr("atm.notifier.discord.DiscordNotifier", _Stub)
|
||
monkeypatch.setattr("atm.notifier.telegram.TelegramNotifier", _Stub)
|
||
monkeypatch.setattr("atm.audit.AuditLog", _Stub)
|
||
monkeypatch.setattr("atm.levels.LevelsExtractor", _Stub)
|
||
monkeypatch.setattr("atm.main._build_capture", fake_build_capture)
|
||
monkeypatch.setattr("atm.main.time.sleep", lambda s: None)
|
||
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)
|
||
|
||
arm = [a for a in captured if a.kind == "arm"]
|
||
prime = [a for a in captured if a.kind == "prime"]
|
||
trigger = [a for a in captured if a.kind == "trigger"]
|
||
|
||
assert len(arm) == 1, f"expected 1 arm alert, got {len(arm)} ({[a.title for a in captured]})"
|
||
assert arm[0].direction == "SELL"
|
||
assert "recuperare" in (arm[0].title + arm[0].body).lower()
|
||
|
||
assert len(prime) == 1
|
||
assert prime[0].direction == "SELL"
|
||
assert "recuperare" in (prime[0].title + prime[0].body).lower()
|
||
|
||
assert len(trigger) == 1
|
||
assert trigger[0].direction == "SELL"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MUST-HAVE: async lifecycle integration test
|
||
# IDLE → ARMED → PRIMED (auto-poll scheduler starts) → FIRE (scheduler stops)
|
||
# Tests: scheduler starts on prime, stops on fire, fire alert sent.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_lifecycle_idle_armed_primed_autopoll_fire_stop(monkeypatch, tmp_path):
|
||
import numpy as np
|
||
import atm.main as _main
|
||
from atm.detector import DetectionResult
|
||
|
||
captured_alerts: list = []
|
||
scheduler_events: list[str] = []
|
||
|
||
class FakeFanout:
|
||
def __init__(self, *a, **kw): pass
|
||
def send(self, alert): captured_alerts.append(alert)
|
||
def stop(self): pass
|
||
def stats(self): return {}
|
||
|
||
class FakeCanaryResult:
|
||
distance = 0
|
||
drifted = False
|
||
paused = False
|
||
|
||
class FakeCanary:
|
||
def __init__(self, *a, **kw): self.is_paused = False
|
||
def check(self, frame): return FakeCanaryResult()
|
||
def resume(self): pass
|
||
|
||
# Scheduler tracks start/stop calls
|
||
class FakeScheduler:
|
||
def __init__(self, *a, **kw):
|
||
self.is_running = False
|
||
self.interval_s = None
|
||
def start(self, interval_s):
|
||
self.is_running = True
|
||
self.interval_s = interval_s
|
||
scheduler_events.append(f"start:{interval_s}")
|
||
def stop(self):
|
||
self.is_running = False
|
||
scheduler_events.append("stop")
|
||
async def run(self):
|
||
await asyncio.sleep(9999)
|
||
|
||
class FakePoller:
|
||
def __init__(self, *a, **kw): pass
|
||
async def run(self): await asyncio.sleep(9999)
|
||
|
||
class _StopLoop(Exception): pass
|
||
|
||
class ScriptedDetector:
|
||
# turquoise→ARM, dark_green→PRIME, light_green→FIRE
|
||
_script = [
|
||
("turquoise", True),
|
||
("dark_green", True),
|
||
("light_green", True),
|
||
]
|
||
def __init__(self, *a, **kw): self._i = 0
|
||
def step(self, ts, frame=None):
|
||
if self._i >= len(self._script):
|
||
raise _StopLoop
|
||
color, accepted = self._script[self._i]
|
||
self._i += 1
|
||
return DetectionResult(ts=ts, window_found=True, dot_found=True,
|
||
rgb=(1, 1, 1), match=None, accepted=accepted, color=color)
|
||
@property
|
||
def rolling(self): return []
|
||
|
||
def fake_build_capture(cfg, capture_stub=False):
|
||
return lambda: np.zeros((50, 50, 3), dtype=np.uint8)
|
||
|
||
cfg = MagicMock()
|
||
cfg.lockout_s = 60
|
||
cfg.heartbeat_min = 999
|
||
cfg.loop_interval_s = 0
|
||
cfg.config_version = "test"
|
||
cfg.dead_letter_path = str(tmp_path / "dl.jsonl")
|
||
cfg.canary.drift_threshold = 10
|
||
cfg.dot_roi.x = 0; cfg.dot_roi.y = 0; cfg.dot_roi.w = 10; cfg.dot_roi.h = 10
|
||
cfg.chart_window_region = None
|
||
cfg.telegram.auto_poll_interval_s = 180
|
||
cfg.telegram.bot_token = "tok"
|
||
cfg.telegram.chat_id = "123"
|
||
cfg.telegram.allowed_chat_ids = ("123",)
|
||
|
||
fake_sched = FakeScheduler()
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
class _Stub:
|
||
def __init__(self, *a, **kw): pass
|
||
def log(self, *a, **kw): pass
|
||
def close(self, *a, **kw): pass
|
||
def step(self, *a, **kw): return types.SimpleNamespace(status="pending", levels=None)
|
||
|
||
monkeypatch.setattr("atm.detector.Detector", ScriptedDetector)
|
||
monkeypatch.setattr("atm.canary.Canary", FakeCanary)
|
||
monkeypatch.setattr("atm.notifier.fanout.FanoutNotifier", FakeFanout)
|
||
monkeypatch.setattr("atm.notifier.discord.DiscordNotifier", _Stub)
|
||
monkeypatch.setattr("atm.notifier.telegram.TelegramNotifier", _Stub)
|
||
monkeypatch.setattr("atm.audit.AuditLog", _Stub)
|
||
monkeypatch.setattr("atm.levels.LevelsExtractor", _Stub)
|
||
monkeypatch.setattr("atm.main._build_capture", fake_build_capture)
|
||
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)
|
||
|
||
arm_alerts = [a for a in captured_alerts if a.kind == "arm"]
|
||
prime_alerts = [a for a in captured_alerts if a.kind == "prime"]
|
||
trigger_alerts = [a for a in captured_alerts if a.kind == "trigger"]
|
||
|
||
assert len(arm_alerts) == 1, f"expected 1 arm, got {[a.title for a in captured_alerts]}"
|
||
assert arm_alerts[0].direction == "BUY"
|
||
|
||
assert len(prime_alerts) == 1
|
||
assert prime_alerts[0].direction == "BUY"
|
||
|
||
assert len(trigger_alerts) == 1
|
||
assert trigger_alerts[0].direction == "BUY"
|
||
|
||
# Scheduler must have started (on PRIME) and stopped (on FIRE)
|
||
assert "start:180" in scheduler_events, f"scheduler not started: {scheduler_events}"
|
||
assert "stop" in scheduler_events, f"scheduler not stopped: {scheduler_events}"
|
||
start_idx = scheduler_events.index("start:180")
|
||
stop_idx = scheduler_events.index("stop")
|
||
assert start_idx < stop_idx, "scheduler started after it stopped"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commit 1 regression tests: _drain_cmd_queue MUST run unconditionally,
|
||
# even when canary is paused or when detection is otherwise skipped.
|
||
# Prior bug: `continue` past the drain loop caused commands to pile up.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_ctx_for_drain(cmd_queue, dispatched: list):
|
||
"""Build a minimal RunContext where _dispatch_command just records calls."""
|
||
import atm.main as _main
|
||
|
||
class _FakeAudit:
|
||
def __init__(self): self.events = []
|
||
def log(self, e): self.events.append(e)
|
||
|
||
class _FakeNotifier:
|
||
def __init__(self): self.alerts = []
|
||
def send(self, a): self.alerts.append(a)
|
||
|
||
class _FakeCanary:
|
||
def __init__(self, paused=True):
|
||
self.is_paused = paused
|
||
|
||
class _FakeScheduler:
|
||
is_running = False
|
||
interval_s = None
|
||
def start(self, s): pass
|
||
def stop(self): pass
|
||
|
||
state = _main._LoopState(start=0.0)
|
||
ctx = _main.RunContext(
|
||
cfg=MagicMock(),
|
||
capture=lambda: None,
|
||
canary=_FakeCanary(paused=True),
|
||
detector=MagicMock(),
|
||
fsm=MagicMock(),
|
||
notifier=_FakeNotifier(),
|
||
audit=_FakeAudit(),
|
||
detection_log=_FakeAudit(),
|
||
scheduler=_FakeScheduler(),
|
||
samples_dir=Path("."),
|
||
fires_dir=Path("."),
|
||
cmd_queue=cmd_queue,
|
||
state=state,
|
||
levels_extractor_factory=lambda *a, **kw: None,
|
||
)
|
||
return ctx
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_drain_works_when_canary_paused(monkeypatch):
|
||
"""Regression: when canary.is_paused, _drain_cmd_queue still dispatches.
|
||
|
||
Prior bug: detection loop `continue`'d past the drain block whenever the
|
||
tick returned res=None (canary paused). Commands accumulated forever.
|
||
"""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
q: asyncio.Queue = asyncio.Queue()
|
||
await q.put(Command(action="status"))
|
||
await q.put(Command(action="ss"))
|
||
|
||
dispatched: list = []
|
||
|
||
async def _fake_dispatch(ctx, cmd):
|
||
dispatched.append(cmd.action)
|
||
|
||
monkeypatch.setattr(_main, "_dispatch_command", _fake_dispatch)
|
||
|
||
ctx = _make_ctx_for_drain(q, dispatched)
|
||
|
||
await _main._drain_cmd_queue(ctx)
|
||
|
||
assert dispatched == ["status", "ss"]
|
||
assert q.empty()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_drain_works_when_out_of_window(monkeypatch):
|
||
"""Drain must still fire when the tick skipped (e.g. out of operating hours).
|
||
|
||
The refactored loop runs _drain_cmd_queue unconditionally after every tick,
|
||
regardless of `_TickSyncResult` content.
|
||
"""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
q: asyncio.Queue = asyncio.Queue()
|
||
await q.put(Command(action="stop"))
|
||
|
||
dispatched: list = []
|
||
|
||
async def _fake_dispatch(ctx, cmd):
|
||
dispatched.append(cmd.action)
|
||
|
||
monkeypatch.setattr(_main, "_dispatch_command", _fake_dispatch)
|
||
|
||
ctx = _make_ctx_for_drain(q, dispatched)
|
||
# Simulate out-of-window tick (empty _TickSyncResult, no res)
|
||
await _main._handle_fsm_result(ctx, _main._TickSyncResult())
|
||
await _main._drain_cmd_queue(ctx)
|
||
|
||
assert dispatched == ["stop"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_drain_isolates_dispatch_exceptions(monkeypatch):
|
||
"""If one command raises, remaining commands still drain + warn alert sent."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
q: asyncio.Queue = asyncio.Queue()
|
||
await q.put(Command(action="status"))
|
||
await q.put(Command(action="ss"))
|
||
|
||
attempts: list = []
|
||
|
||
async def _fake_dispatch(ctx, cmd):
|
||
attempts.append(cmd.action)
|
||
if cmd.action == "status":
|
||
raise RuntimeError("boom")
|
||
|
||
monkeypatch.setattr(_main, "_dispatch_command", _fake_dispatch)
|
||
|
||
ctx = _make_ctx_for_drain(q, attempts)
|
||
await _main._drain_cmd_queue(ctx)
|
||
|
||
assert attempts == ["status", "ss"]
|
||
# warn alert for the failed command
|
||
warn_titles = [a.title for a in ctx.notifier.alerts if a.kind == "warn"]
|
||
assert any("status" in t for t in warn_titles)
|
||
# command_error audit event
|
||
errs = [e for e in ctx.audit.events if e.get("event") == "command_error"]
|
||
assert len(errs) == 1 and errs[0]["action"] == "status"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commit 4: operating hours + LifecycleState transitions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
from zoneinfo import ZoneInfo as _ZI # noqa: E402
|
||
import datetime as _dt # noqa: E402
|
||
|
||
|
||
def _oh_cfg(enabled=True, weekdays=("MON", "TUE", "WED", "THU", "FRI"),
|
||
start="09:30", stop="16:00", tz="America/New_York"):
|
||
"""Build a lightweight cfg-like object with operating_hours populated."""
|
||
oh = types.SimpleNamespace(
|
||
enabled=enabled,
|
||
timezone=tz,
|
||
weekdays=weekdays,
|
||
start_hhmm=start,
|
||
stop_hhmm=stop,
|
||
_tz_cache=_ZI(tz) if enabled else None,
|
||
)
|
||
return types.SimpleNamespace(operating_hours=oh, window_title=None)
|
||
|
||
|
||
def _fake_canary(paused=False):
|
||
return types.SimpleNamespace(is_paused=paused)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"local_dt,expected",
|
||
[
|
||
# Monday 09:30 NY — exact open → active (None)
|
||
(_dt.datetime(2026, 4, 20, 9, 30), None),
|
||
# Monday 16:00 NY — exact close → inactive (>= stop)
|
||
(_dt.datetime(2026, 4, 20, 16, 0), "out_of_window_hours"),
|
||
# Monday 08:00 NY — before open
|
||
(_dt.datetime(2026, 4, 20, 8, 0), "out_of_window_hours"),
|
||
# Monday 12:00 NY — active
|
||
(_dt.datetime(2026, 4, 20, 12, 0), None),
|
||
# Saturday 12:00 NY — weekend
|
||
(_dt.datetime(2026, 4, 18, 12, 0), "out_of_window_weekend"),
|
||
# Sunday 23:00 NY — weekend
|
||
(_dt.datetime(2026, 4, 19, 23, 0), "out_of_window_weekend"),
|
||
],
|
||
)
|
||
def test_operating_hours_skip_matrix(local_dt, expected):
|
||
"""Timezone-aware start/stop + weekday checks."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
now_ts = local_dt.replace(tzinfo=tz).timestamp()
|
||
|
||
lifecycle = _main.LifecycleState()
|
||
result = _main._should_skip(now_ts, lifecycle, cfg, _fake_canary())
|
||
assert result == expected
|
||
|
||
|
||
def test_market_open_close_transitions_logged_once():
|
||
"""Crossing a boundary emits exactly one market_open / market_closed event."""
|
||
import atm.main as _main
|
||
|
||
audit_events = []
|
||
alerts = []
|
||
|
||
class _A:
|
||
def log(self, e): audit_events.append(e)
|
||
|
||
class _N:
|
||
def send(self, a): alerts.append(a)
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
lifecycle = _main.LifecycleState()
|
||
canary = _fake_canary()
|
||
|
||
# Prime as closed (before open, Monday 08:00)
|
||
pre_open = _dt.datetime(2026, 4, 20, 8, 0, tzinfo=tz).timestamp()
|
||
skip_pre = _main._should_skip(pre_open, lifecycle, cfg, canary)
|
||
_main._maybe_log_transition(skip_pre, lifecycle, pre_open, _A(), _N())
|
||
# First evaluation seeds state, no alert yet.
|
||
assert lifecycle.last_window_state == "closed"
|
||
assert alerts == []
|
||
assert audit_events == []
|
||
|
||
# Transition to open
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
skip_mid = _main._should_skip(mid, lifecycle, cfg, canary)
|
||
_main._maybe_log_transition(skip_mid, lifecycle, mid, _A(), _N())
|
||
assert lifecycle.last_window_state == "open"
|
||
assert len(alerts) == 1
|
||
assert any(e.get("event") == "market_open" for e in audit_events)
|
||
|
||
# Repeated open tick — no duplicate log
|
||
alerts.clear()
|
||
audit_events.clear()
|
||
skip_mid2 = _main._should_skip(mid + 60, lifecycle, cfg, canary)
|
||
_main._maybe_log_transition(skip_mid2, lifecycle, mid + 60, _A(), _N())
|
||
assert alerts == []
|
||
assert audit_events == []
|
||
|
||
# Transition to close
|
||
close = _dt.datetime(2026, 4, 20, 17, 0, tzinfo=tz).timestamp()
|
||
skip_close = _main._should_skip(close, lifecycle, cfg, canary)
|
||
_main._maybe_log_transition(skip_close, lifecycle, close, _A(), _N())
|
||
assert lifecycle.last_window_state == "closed"
|
||
assert any(e.get("event") == "market_closed" for e in audit_events)
|
||
|
||
|
||
def test_market_transition_sends_notification():
|
||
"""market_open / market_closed transitions produce kind=status alerts."""
|
||
import atm.main as _main
|
||
|
||
alerts = []
|
||
|
||
class _A:
|
||
def log(self, e): pass
|
||
|
||
class _N:
|
||
def send(self, a): alerts.append(a)
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
lifecycle = _main.LifecycleState(last_window_state="closed")
|
||
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
_main._maybe_log_transition(None, lifecycle, mid, _A(), _N())
|
||
assert len(alerts) == 1
|
||
assert alerts[0].kind == "status"
|
||
assert "piața" in alerts[0].title.lower() or "monitorizare" in alerts[0].body.lower()
|
||
|
||
|
||
def test_startup_in_window_suppresses_market_open():
|
||
"""R2 #20: first evaluation in-window just seeds state; no alert fires."""
|
||
import atm.main as _main
|
||
|
||
alerts = []
|
||
events = []
|
||
|
||
class _A:
|
||
def log(self, e): events.append(e)
|
||
|
||
class _N:
|
||
def send(self, a): alerts.append(a)
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
lifecycle = _main.LifecycleState() # last_window_state is None
|
||
|
||
in_window = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
skip = _main._should_skip(in_window, lifecycle, cfg, _fake_canary())
|
||
assert skip is None
|
||
_main._maybe_log_transition(skip, lifecycle, in_window, _A(), _N())
|
||
|
||
# Seeded silently
|
||
assert lifecycle.last_window_state == "open"
|
||
assert alerts == []
|
||
assert not any(e.get("event") == "market_open" for e in events)
|
||
|
||
# Two more ticks, still in-window → no spurious alert
|
||
for _ in range(2):
|
||
skip = _main._should_skip(in_window + 60, lifecycle, cfg, _fake_canary())
|
||
_main._maybe_log_transition(skip, lifecycle, in_window + 60, _A(), _N())
|
||
assert alerts == []
|
||
|
||
|
||
def test_operating_hours_weekday_locale_independent():
|
||
"""R2 #22: weekday check must not depend on process locale (strftime('%a'))."""
|
||
import locale as _locale
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
# Saturday 12:00 NY
|
||
sat = _dt.datetime(2026, 4, 18, 12, 0, tzinfo=tz).timestamp()
|
||
|
||
original = _locale.setlocale(_locale.LC_TIME)
|
||
try:
|
||
for loc in ("C", "de_DE.UTF-8"):
|
||
try:
|
||
_locale.setlocale(_locale.LC_TIME, loc)
|
||
except _locale.Error:
|
||
continue # locale not installed → skip gracefully
|
||
lifecycle = _main.LifecycleState()
|
||
result = _main._should_skip(sat, lifecycle, cfg, _fake_canary())
|
||
assert result == "out_of_window_weekend", (
|
||
f"locale={loc} returned {result!r}"
|
||
)
|
||
finally:
|
||
try:
|
||
_locale.setlocale(_locale.LC_TIME, original)
|
||
except _locale.Error:
|
||
_locale.setlocale(_locale.LC_TIME, "C")
|
||
|
||
|
||
def test_should_skip_user_paused_wins():
|
||
import atm.main as _main
|
||
cfg = _oh_cfg()
|
||
lifecycle = _main.LifecycleState(user_paused=True)
|
||
# Mid-Monday (in-window) — should still skip because user_paused
|
||
tz = cfg.operating_hours._tz_cache
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
assert _main._should_skip(mid, lifecycle, cfg, _fake_canary()) == "user_paused"
|
||
|
||
|
||
def test_should_skip_canary_drift_wins_over_window():
|
||
import atm.main as _main
|
||
cfg = _oh_cfg()
|
||
lifecycle = _main.LifecycleState()
|
||
tz = cfg.operating_hours._tz_cache
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
assert _main._should_skip(mid, lifecycle, cfg, _fake_canary(paused=True)) == "drift_paused"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Session window (/window): local wall-clock, recurring daily, ANDed with oh
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_should_skip_session_window_local_only():
|
||
"""operating_hours off → only the local session window gates detection."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg(enabled=False)
|
||
lifecycle = _main.LifecycleState(session_window=("19:40", "21:45"))
|
||
canary = _fake_canary()
|
||
# Naive datetimes → .timestamp() interprets them as local wall-clock, and
|
||
# _should_skip reads them back via datetime.fromtimestamp (also local), so
|
||
# this is machine-timezone independent.
|
||
inside = _dt.datetime(2026, 4, 20, 20, 30).timestamp()
|
||
before = _dt.datetime(2026, 4, 20, 19, 0).timestamp()
|
||
after = _dt.datetime(2026, 4, 20, 22, 0).timestamp()
|
||
boundary_stop = _dt.datetime(2026, 4, 20, 21, 45).timestamp() # stop is exclusive
|
||
|
||
assert _main._should_skip(inside, lifecycle, cfg, canary) is None
|
||
assert _main._should_skip(before, lifecycle, cfg, canary) == "out_of_window_hours"
|
||
assert _main._should_skip(after, lifecycle, cfg, canary) == "out_of_window_hours"
|
||
assert _main._should_skip(boundary_stop, lifecycle, cfg, canary) == "out_of_window_hours"
|
||
|
||
|
||
def test_should_skip_session_window_no_window_is_open():
|
||
import atm.main as _main
|
||
cfg = _oh_cfg(enabled=False)
|
||
lifecycle = _main.LifecycleState(session_window=None)
|
||
any_ts = _dt.datetime(2026, 4, 20, 3, 0).timestamp()
|
||
assert _main._should_skip(any_ts, lifecycle, cfg, _fake_canary()) is None
|
||
|
||
|
||
def test_should_skip_operating_hours_wins_over_session_window():
|
||
"""Out of operating_hours → skip even if the local session window is open."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg() # NY 09:30-16:00 enabled
|
||
tz = cfg.operating_hours._tz_cache
|
||
pre_open = _dt.datetime(2026, 4, 20, 8, 0, tzinfo=tz).timestamp() # NY pre-open
|
||
# Session window that contains the corresponding local time (so only oh skips).
|
||
local_hhmm = _dt.datetime.fromtimestamp(pre_open).strftime("%H:%M")
|
||
lifecycle = _main.LifecycleState(session_window=("00:00", "23:59"))
|
||
skip = _main._should_skip(pre_open, lifecycle, cfg, _fake_canary())
|
||
assert skip == "out_of_window_hours"
|
||
# sanity: local time really is inside the wide session window
|
||
assert "00:00" <= local_hhmm < "23:59"
|
||
|
||
|
||
def test_should_skip_session_window_ands_inside_operating_hours():
|
||
"""Inside operating_hours but outside the local session window → skip."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg() # NY 09:30-16:00 enabled
|
||
tz = cfg.operating_hours._tz_cache
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp() # in NY window
|
||
local_hhmm = _dt.datetime.fromtimestamp(mid).strftime("%H:%M")
|
||
h = int(local_hhmm[:2])
|
||
# 1-minute window two hours away from the current local time → excludes it.
|
||
off = (h + 2) % 24
|
||
lifecycle = _main.LifecycleState(session_window=(f"{off:02d}:00", f"{off:02d}:01"))
|
||
assert _main._should_skip(mid, lifecycle, cfg, _fake_canary()) == "out_of_window_hours"
|
||
|
||
|
||
def test_handle_market_closed_stops_scheduler_and_resets_fsm():
|
||
import atm.main as _main
|
||
from atm.state_machine import State, StateMachine
|
||
|
||
ctx = _dispatch_ctx()
|
||
ctx.cfg.lockout_s = 240
|
||
ctx.scheduler.is_running = True
|
||
ctx.charts = []
|
||
ctx.state.n_primed_global = 2
|
||
|
||
_main._handle_market_closed(ctx, 0.0)
|
||
|
||
assert ctx.scheduler.is_running is False
|
||
assert ctx.state.n_primed_global == 0
|
||
assert isinstance(ctx.fsm, StateMachine)
|
||
assert ctx.fsm.state == State.IDLE
|
||
assert any(
|
||
e.get("event") == "scheduler_stopped" and e.get("reason") == "market_closed"
|
||
for e in ctx.audit.events
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_command_sets_and_clears_session_window():
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
ctx = _dispatch_ctx()
|
||
await _main._dispatch_command(ctx, Command(action="window", window=("19:40", "21:45")))
|
||
assert ctx.lifecycle.session_window == ("19:40", "21:45")
|
||
assert any(e.get("event") == "session_window_set" for e in ctx.audit.events)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="window", window=None))
|
||
assert ctx.lifecycle.session_window is None
|
||
assert any(e.get("event") == "session_window_cleared" for e in ctx.audit.events)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_command_persists_across_restart(tmp_path):
|
||
"""/window is saved to disk and restored on the next start; /window off deletes it."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
sw_path = tmp_path / "logs" / "session_window.json"
|
||
ctx = _dispatch_ctx(lifecycle=_main.LifecycleState(session_window_path=sw_path))
|
||
await _main._dispatch_command(ctx, Command(action="window", window=("19:30", "22:00")))
|
||
assert _main._load_session_window(sw_path) == ("19:30", "22:00")
|
||
|
||
await _main._dispatch_command(ctx, Command(action="window", window=None))
|
||
assert not sw_path.exists()
|
||
assert _main._load_session_window(sw_path) is None
|
||
|
||
|
||
def test_load_session_window_corrupt_file_is_none(tmp_path):
|
||
import atm.main as _main
|
||
|
||
p = tmp_path / "session_window.json"
|
||
p.write_text("{not json", encoding="utf-8")
|
||
assert _main._load_session_window(p) is None
|
||
p.write_text('{"start": "19:30"}', encoding="utf-8")
|
||
assert _main._load_session_window(p) is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_open_close_alerts_sent_while_user_paused():
|
||
"""Daily open/close alerts are the alive signal → they fire even when paused."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg()
|
||
cfg.lockout_s = 240
|
||
tz = cfg.operating_hours._tz_cache
|
||
lifecycle = _main.LifecycleState(user_paused=True, last_window_state="closed")
|
||
ctx = _dispatch_ctx(lifecycle=lifecycle, cfg=cfg)
|
||
ctx.charts = []
|
||
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
assert await _main._lifecycle_gate(ctx, mid) == "user_paused"
|
||
assert lifecycle.last_window_state == "open"
|
||
opened = [a for a in ctx.notifier.alerts if "deschisă" in a.title]
|
||
assert len(opened) == 1 and "oprită manual" in opened[0].body
|
||
# Paused → TradeStation isn't pulled to the front on open.
|
||
assert not any(e.get("event") == "window_focused" for e in ctx.audit.events)
|
||
|
||
close = _dt.datetime(2026, 4, 20, 16, 5, tzinfo=tz).timestamp()
|
||
assert await _main._lifecycle_gate(ctx, close) == "user_paused"
|
||
assert lifecycle.last_window_state == "closed"
|
||
assert sum("închisă" in a.title for a in ctx.notifier.alerts) == 1
|
||
|
||
# Same side of the boundary again → no duplicate.
|
||
n = len(ctx.notifier.alerts)
|
||
await _main._lifecycle_gate(ctx, close + 60)
|
||
assert len(ctx.notifier.alerts) == n
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Commit 5: /pause /resume dispatch (plan tests #11-15, #16, R2 #21)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _dispatch_ctx(canary=None, lifecycle=None, cfg=None):
|
||
"""Minimal RunContext for _dispatch_command unit tests."""
|
||
import atm.main as _main
|
||
|
||
class _A:
|
||
def __init__(self): self.events = []
|
||
def log(self, e): self.events.append(e)
|
||
|
||
class _N:
|
||
def __init__(self): self.alerts = []
|
||
def send(self, a): self.alerts.append(a)
|
||
|
||
class _S:
|
||
is_running = False
|
||
interval_s = None
|
||
def start(self, s): self.is_running = True
|
||
def stop(self): self.is_running = False
|
||
|
||
if canary is None:
|
||
canary = types.SimpleNamespace(is_paused=False, resume=lambda: None)
|
||
if lifecycle is None:
|
||
lifecycle = _main.LifecycleState()
|
||
if cfg is None:
|
||
cfg = MagicMock()
|
||
cfg.telegram.auto_poll_interval_s = 180
|
||
cfg.operating_hours = types.SimpleNamespace(enabled=False, _tz_cache=None)
|
||
# Skip window focus in dispatch tests — MagicMock window_title would
|
||
# propagate into _focus_window_by_title (real Win32 call).
|
||
cfg.window_title = None
|
||
|
||
state = _main._LoopState(start=0.0)
|
||
ctx = _main.RunContext(
|
||
cfg=cfg, capture=lambda: None, canary=canary,
|
||
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,
|
||
lifecycle=lifecycle,
|
||
)
|
||
return ctx
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pause_command_sets_user_paused_and_skips_detection():
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
ctx = _dispatch_ctx()
|
||
await _main._dispatch_command(ctx, Command(action="pause"))
|
||
|
||
assert ctx.lifecycle.user_paused is True
|
||
# When combined with _should_skip, we get user_paused
|
||
assert _main._should_skip(0.0, ctx.lifecycle, ctx.cfg, ctx.canary) == "user_paused"
|
||
# Audit + notif
|
||
assert any(e.get("event") == "user_paused" for e in ctx.audit.events)
|
||
assert any(a.kind == "status" and "oprit" in a.title.lower() for a in ctx.notifier.alerts)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resume_clears_user_paused_and_canary_when_forced():
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
canary_state = {"paused": True}
|
||
canary = types.SimpleNamespace(
|
||
is_paused=True,
|
||
resume=lambda: canary_state.__setitem__("paused", False),
|
||
)
|
||
# Re-bind is_paused via property so resume() effect is visible
|
||
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
|
||
force_events = [e for e in ctx.audit.events if e.get("event") == "user_resumed"]
|
||
assert force_events and force_events[0]["force"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
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
|
||
|
||
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")) # no force
|
||
|
||
assert ctx.lifecycle.user_paused is False
|
||
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 (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
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /rebase: propose + confirm flow
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _rebase_cfg_and_path(tmp_path):
|
||
"""Write a minimal TOML with baseline_phash, return (cfg_live, path, cfg_version)."""
|
||
name = "rebase_test"
|
||
path = tmp_path / "configs" / f"{name}.toml"
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
old = "a" * 64
|
||
path.write_text(
|
||
f'[canary]\n'
|
||
f'baseline_phash = "{old}" # comment stays\n'
|
||
f'drift_threshold = 8\n'
|
||
f'[canary.roi]\nx = 0\ny = 0\nw = 4\nh = 4\n',
|
||
encoding="utf-8",
|
||
)
|
||
cfg = types.SimpleNamespace(
|
||
window_title=None,
|
||
config_version=name,
|
||
canary=types.SimpleNamespace(
|
||
roi=types.SimpleNamespace(x=0, y=0, w=4, h=4),
|
||
baseline_phash=old,
|
||
drift_threshold=8,
|
||
),
|
||
telegram=types.SimpleNamespace(auto_poll_interval_s=180),
|
||
operating_hours=types.SimpleNamespace(enabled=False, _tz_cache=None),
|
||
)
|
||
return cfg, path, old
|
||
|
||
|
||
def _blue_frame(h=200, w=200):
|
||
import numpy as _np
|
||
return _np.full((h, w, 3), (50, 80, 20), dtype=_np.uint8)
|
||
|
||
|
||
def test_rewrite_baseline_phash_updates_only_target_line(tmp_path):
|
||
import atm.main as _main
|
||
p = tmp_path / "cfg.toml"
|
||
p.write_text(
|
||
'[canary]\n'
|
||
'baseline_phash = "deadbeef" # keep this comment\n'
|
||
'drift_threshold = 8\n',
|
||
encoding="utf-8",
|
||
)
|
||
old = _main._rewrite_baseline_phash(p, "cafef00d")
|
||
assert old == "deadbeef"
|
||
txt = p.read_text(encoding="utf-8")
|
||
assert 'baseline_phash = "cafef00d" # keep this comment' in txt
|
||
assert "drift_threshold = 8" in txt
|
||
|
||
|
||
def test_rewrite_baseline_phash_raises_when_missing(tmp_path):
|
||
import atm.main as _main
|
||
p = tmp_path / "cfg.toml"
|
||
p.write_text("[canary]\ndrift_threshold = 8\n", encoding="utf-8")
|
||
with pytest.raises(ValueError):
|
||
_main._rewrite_baseline_phash(p, "ff")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_propose_sets_pending_and_does_not_touch_file(tmp_path, monkeypatch):
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg, cfg_path, old = _rebase_cfg_and_path(tmp_path)
|
||
ctx = _dispatch_ctx(cfg=cfg)
|
||
ctx.capture = lambda: _blue_frame()
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase"))
|
||
|
||
assert ctx.pending_rebase is not None
|
||
proposed_ts, new_phash, pending_path = ctx.pending_rebase
|
||
assert new_phash != old
|
||
assert pending_path == Path("configs") / f"{cfg.config_version}.toml"
|
||
# File unchanged at propose time
|
||
assert f'"{old}"' in cfg_path.read_text(encoding="utf-8")
|
||
events = [e["event"] for e in ctx.audit.events]
|
||
assert "rebase_proposed" in events
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_confirm_applies_and_clears_pauses(tmp_path, monkeypatch):
|
||
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
|
||
|
||
cfg, cfg_path, old = _rebase_cfg_and_path(tmp_path)
|
||
canary = _Canary()
|
||
ctx = _dispatch_ctx(cfg=cfg, canary=canary)
|
||
ctx.capture = lambda: _blue_frame()
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
ctx.lifecycle.user_paused = True
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase"))
|
||
assert ctx.pending_rebase is not None
|
||
_, new_phash, _ = ctx.pending_rebase
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase", value=1))
|
||
|
||
assert ctx.pending_rebase is None
|
||
assert ctx.lifecycle.user_paused is False
|
||
assert canary.is_paused is False
|
||
assert cfg.canary.baseline_phash == new_phash
|
||
txt = cfg_path.read_text(encoding="utf-8")
|
||
assert f'"{new_phash}"' in txt
|
||
assert f'"{old}"' not in txt
|
||
applied = [e for e in ctx.audit.events if e.get("event") == "rebase_applied"]
|
||
assert applied and applied[0]["old_phash"] == old and applied[0]["new_phash"] == new_phash
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_confirm_without_pending_warns(tmp_path):
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg, _, _ = _rebase_cfg_and_path(tmp_path)
|
||
ctx = _dispatch_ctx(cfg=cfg)
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase", value=1))
|
||
|
||
assert ctx.pending_rebase is None
|
||
alerts = ctx.notifier.alerts
|
||
assert alerts and "nimic" in alerts[0].title.lower()
|
||
applied = [e for e in ctx.audit.events if e.get("event") == "rebase_applied"]
|
||
assert not applied
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_confirm_expired_pending_warns(tmp_path, monkeypatch):
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg, cfg_path, old = _rebase_cfg_and_path(tmp_path)
|
||
ctx = _dispatch_ctx(cfg=cfg)
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
# Pretend propose happened 1h ago
|
||
import time as _t
|
||
ctx.pending_rebase = (_t.time() - 3600, "b" * 64, cfg_path)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase", value=1))
|
||
|
||
assert ctx.pending_rebase is None
|
||
# File untouched
|
||
assert f'"{old}"' in cfg_path.read_text(encoding="utf-8")
|
||
alerts = ctx.notifier.alerts
|
||
assert alerts and "expirat" in alerts[0].title.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_confirm_mutates_frozen_canary_region(tmp_path, monkeypatch):
|
||
"""Regression: CanaryRegion is @dataclass(frozen=True). Plain assignment
|
||
raises FrozenInstanceError. /rebase confirm must mirror the new phash into
|
||
the live cfg anyway — otherwise canary.check() keeps the stale hash and
|
||
drift re-pauses within one tick after the user confirms."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
from atm.config import CanaryRegion, ROI as _ROI
|
||
|
||
class _Canary:
|
||
def __init__(self): self._p = False
|
||
@property
|
||
def is_paused(self): return self._p
|
||
def resume(self): self._p = False
|
||
|
||
# Build cfg with the REAL frozen dataclass, not a SimpleNamespace.
|
||
_, cfg_path, old = _rebase_cfg_and_path(tmp_path)
|
||
cfg = types.SimpleNamespace(
|
||
window_title=None,
|
||
config_version="rebase_test",
|
||
canary=CanaryRegion(
|
||
roi=_ROI(x=0, y=0, w=4, h=4),
|
||
baseline_phash=old,
|
||
drift_threshold=8,
|
||
),
|
||
telegram=types.SimpleNamespace(auto_poll_interval_s=180),
|
||
operating_hours=types.SimpleNamespace(enabled=False, _tz_cache=None),
|
||
)
|
||
ctx = _dispatch_ctx(cfg=cfg, canary=_Canary())
|
||
ctx.capture = lambda: _blue_frame()
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase"))
|
||
_, new_phash, _ = ctx.pending_rebase
|
||
await _main._dispatch_command(ctx, Command(action="rebase", value=1))
|
||
|
||
# Live cfg mirrors the new hash (in-memory canary.check() sees it)
|
||
assert cfg.canary.baseline_phash == new_phash
|
||
# TOML on disk also updated
|
||
assert f'"{new_phash}"' in cfg_path.read_text(encoding="utf-8")
|
||
applied = [e for e in ctx.audit.events if e.get("event") == "rebase_applied"]
|
||
assert applied
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_rebase_propose_capture_failed_warns(tmp_path, monkeypatch):
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg, _, _ = _rebase_cfg_and_path(tmp_path)
|
||
ctx = _dispatch_ctx(cfg=cfg)
|
||
ctx.capture = lambda: None
|
||
ctx.fires_dir = tmp_path / "fires"
|
||
ctx.fires_dir.mkdir()
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
await _main._dispatch_command(ctx, Command(action="rebase"))
|
||
|
||
assert ctx.pending_rebase is None
|
||
alerts = ctx.notifier.alerts
|
||
assert alerts and "e" in alerts[0].title.lower() # "Captură eșuată"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resume_out_of_window_responds_with_pending_message():
|
||
"""/resume while operating-hours window is closed → special body."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
lifecycle = _main.LifecycleState(user_paused=True, last_window_state="closed")
|
||
canary = types.SimpleNamespace(is_paused=False, resume=lambda: None)
|
||
|
||
ctx = _dispatch_ctx(canary=canary, lifecycle=lifecycle, cfg=cfg)
|
||
|
||
# Pin time to Saturday
|
||
import atm.main as _mm
|
||
real_time = _mm.time
|
||
fake_ts = _dt.datetime(2026, 4, 18, 12, 0, tzinfo=tz).timestamp()
|
||
class _FakeTime:
|
||
def time(self): return fake_ts
|
||
def monotonic(self): return 0.0
|
||
_mm.time = _FakeTime()
|
||
try:
|
||
await _main._dispatch_command(ctx, Command(action="resume"))
|
||
finally:
|
||
_mm.time = real_time
|
||
|
||
assert ctx.lifecycle.user_paused is False
|
||
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."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
ctx = _dispatch_ctx()
|
||
ctx.lifecycle.user_paused = True
|
||
# Stub detector.rolling for status
|
||
ctx.detector.rolling = []
|
||
ctx.fsm.state = types.SimpleNamespace(value="IDLE")
|
||
|
||
await _main._dispatch_command(ctx, Command(action="status"))
|
||
|
||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||
assert status
|
||
body = status[0].body
|
||
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_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"))
|
||
|
||
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
|
||
# 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 contains caption only when canary is not paused (no warn prefix)."""
|
||
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
|
||
_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
|
||
# 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
|
||
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.
|
||
|
||
This test verifies the full command-driven lifecycle in isolation:
|
||
- canary starts drift-paused, _should_skip returns drift_paused
|
||
- /resume force clears canary + user_paused
|
||
- subsequent detection produces SELL fire through normal FSM path
|
||
"""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
# Canary with mutable pause state
|
||
class _Canary:
|
||
def __init__(self): self._p = True
|
||
@property
|
||
def is_paused(self): return self._p
|
||
def resume(self): self._p = False
|
||
|
||
canary = _Canary()
|
||
cfg = MagicMock()
|
||
cfg.telegram.auto_poll_interval_s = 180
|
||
cfg.operating_hours = types.SimpleNamespace(enabled=False, _tz_cache=None)
|
||
cfg.window_title = None # skip Win32 focus path in unit test
|
||
|
||
ctx = _dispatch_ctx(canary=canary, cfg=cfg)
|
||
|
||
# 1. While drift-paused, _should_skip returns drift_paused
|
||
assert _main._should_skip(0.0, ctx.lifecycle, cfg, canary) == "drift_paused"
|
||
|
||
# 2. User issues /resume force
|
||
await _main._dispatch_command(ctx, Command(action="resume", value=1))
|
||
assert canary.is_paused is False
|
||
assert _main._should_skip(0.0, ctx.lifecycle, cfg, canary) is None
|
||
|
||
# 3. Feed a yellow→light_red sequence through _handle_tick (FSM path)
|
||
from atm.state_machine import StateMachine, State
|
||
fsm = StateMachine(lockout_s=60)
|
||
|
||
class _N:
|
||
def __init__(self): self.alerts = []
|
||
def send(self, a): self.alerts.append(a)
|
||
|
||
class _A:
|
||
def log(self, _e): pass
|
||
|
||
notif = _N()
|
||
audit = _A()
|
||
cfg_mock = types.SimpleNamespace(alerts=types.SimpleNamespace(fire_on_phase_skip=True))
|
||
|
||
_main._handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False, cfg=cfg_mock)
|
||
_main._handle_tick(fsm, "dark_red", 2.0, notif, audit, first_accepted=False, cfg=cfg_mock)
|
||
tr = _main._handle_tick(fsm, "light_red", 3.0, notif, audit, first_accepted=False, cfg=cfg_mock)
|
||
|
||
# FSM reached fire via normal path
|
||
assert tr is not None and tr.trigger == "SELL"
|
||
assert fsm.state == State.IDLE
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _in_trading_window tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
import datetime as _dt
|
||
|
||
|
||
def test_in_trading_window_disabled():
|
||
"""Returns True when operating_hours disabled."""
|
||
import atm.main as _main
|
||
cfg = types.SimpleNamespace(operating_hours=types.SimpleNamespace(enabled=False))
|
||
assert _main._in_trading_window(0.0, cfg) is True
|
||
|
||
|
||
def test_in_trading_window_no_oh():
|
||
"""Returns True when cfg has no operating_hours attr."""
|
||
import atm.main as _main
|
||
cfg = types.SimpleNamespace()
|
||
assert _main._in_trading_window(0.0, cfg) is True
|
||
|
||
|
||
def test_in_trading_window_in_window():
|
||
"""Returns True during configured hours (Monday 12:00 NY)."""
|
||
import atm.main as _main
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
ts = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
assert _main._in_trading_window(ts, cfg) is True
|
||
|
||
|
||
def test_in_trading_window_out_of_hours():
|
||
"""Returns False before market open (Monday 08:00 NY)."""
|
||
import atm.main as _main
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
ts = _dt.datetime(2026, 4, 20, 8, 0, tzinfo=tz).timestamp()
|
||
assert _main._in_trading_window(ts, cfg) is False
|
||
|
||
|
||
def test_in_trading_window_weekend():
|
||
"""Returns False on weekend (Sunday 12:00 NY)."""
|
||
import atm.main as _main
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
ts = _dt.datetime(2026, 4, 19, 12, 0, tzinfo=tz).timestamp()
|
||
assert _main._in_trading_window(ts, cfg) is False
|
||
|
||
|
||
def test_heartbeat_suppressed_outside_hours(monkeypatch):
|
||
"""_in_trading_window=False prevents heartbeat from sending."""
|
||
import atm.main as _main
|
||
monkeypatch.setattr(_main, "_in_trading_window", lambda ts, cfg: False)
|
||
sent = []
|
||
# Simulate the check: outside window → no send
|
||
if not _main._in_trading_window(0.0, None):
|
||
pass # heartbeat_due reset, continue — no send
|
||
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."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
ctx = _dispatch_ctx()
|
||
ctx.detector.rolling = []
|
||
ctx.fsm.state = types.SimpleNamespace(value="ARMED_BUY")
|
||
ctx.state.fire_count = 3
|
||
|
||
await _main._dispatch_command(ctx, Command(action="status"))
|
||
|
||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||
assert status
|
||
body = status[0].body
|
||
lines = body.splitlines()
|
||
assert len(lines) == 2 # no fereastră line (oh.enabled=False)
|
||
assert "ARMED_BUY" in lines[0]
|
||
assert "semnale: 3" in lines[0]
|
||
assert "Canary" not in body
|
||
assert "canary" not in body
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_status_compact_paused_manual():
|
||
"""/status shows 'pauză manuală' on line 1 when user_paused."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
ctx = _dispatch_ctx()
|
||
ctx.lifecycle.user_paused = True
|
||
ctx.detector.rolling = []
|
||
ctx.fsm.state = types.SimpleNamespace(value="IDLE")
|
||
|
||
await _main._dispatch_command(ctx, Command(action="status"))
|
||
|
||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||
body = status[0].body
|
||
assert body.startswith("pauză manuală")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_status_window_line_when_oh_enabled():
|
||
"""/status adds fereastră line when operating_hours enabled."""
|
||
import atm.main as _main
|
||
from atm.commands import Command
|
||
|
||
cfg = _oh_cfg()
|
||
lifecycle = _main.LifecycleState(last_window_state="open")
|
||
ctx = _dispatch_ctx(lifecycle=lifecycle, cfg=cfg)
|
||
ctx.detector.rolling = []
|
||
ctx.fsm.state = types.SimpleNamespace(value="IDLE")
|
||
|
||
await _main._dispatch_command(ctx, Command(action="status"))
|
||
|
||
status = [a for a in ctx.notifier.alerts if a.kind == "status"]
|
||
body = status[0].body
|
||
# Open/closed depends on the real clock here; exact content is covered by
|
||
# the _window_status_lines tests below with fixed timestamps.
|
||
assert "fereastră: " in body
|
||
assert "/window: —" in body
|
||
assert "bursă: 09:30–16:00 America/New_York MON–FRI" in body
|
||
|
||
|
||
def test_window_status_lines_session_window_only():
|
||
"""Local /window only (oh off) → open/closed + next change in local time."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg(enabled=False)
|
||
lifecycle = _main.LifecycleState(session_window=("19:30", "22:00"))
|
||
inside = _dt.datetime(2026, 4, 20, 20, 0).timestamp()
|
||
assert _main._window_status_lines(inside, lifecycle, cfg) == [
|
||
"fereastră: deschisă → închidere azi 22:00",
|
||
"/window: 19:30–22:00 (local)",
|
||
]
|
||
after = _dt.datetime(2026, 4, 20, 22, 30).timestamp()
|
||
assert _main._window_status_lines(after, lifecycle, cfg)[0] == (
|
||
"fereastră: închisă → deschidere mâine 19:30"
|
||
)
|
||
|
||
|
||
def test_window_status_lines_nothing_configured_is_empty():
|
||
import atm.main as _main
|
||
lines = _main._window_status_lines(0.0, _main.LifecycleState(), _oh_cfg(enabled=False))
|
||
assert lines == []
|
||
|
||
|
||
def test_window_status_lines_exchange_hours_local_conversion():
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp()
|
||
loc = lambda h, m: _dt.datetime(2026, 4, 20, h, m, tzinfo=tz).astimezone().strftime("%H:%M") # noqa: E731
|
||
lines = _main._window_status_lines(mid, _main.LifecycleState(), cfg)
|
||
assert lines[0].startswith("fereastră: deschisă → închidere ")
|
||
assert lines[1] == "/window: —"
|
||
assert lines[2] == (
|
||
f"bursă: 09:30–16:00 America/New_York MON–FRI (local {loc(9, 30)}–{loc(16, 0)})"
|
||
)
|
||
|
||
|
||
def test_next_window_change_skips_weekend():
|
||
"""Friday after close → next flip is Monday's open, not Saturday."""
|
||
import atm.main as _main
|
||
|
||
cfg = _oh_cfg()
|
||
tz = cfg.operating_hours._tz_cache
|
||
fri_evening = _dt.datetime(2026, 4, 24, 17, 0, tzinfo=tz).timestamp()
|
||
mon_open = _dt.datetime(2026, 4, 27, 9, 30, tzinfo=tz).timestamp()
|
||
assert _main._next_window_change(fri_evening, _main.LifecycleState(), cfg) == mon_open
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Auto-rebase on layout change (two-signal gate: drift + strip-count change)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _FakeCanary:
|
||
"""Stand-in for atm.canary.Canary that lets tests drive drift state.
|
||
|
||
Mirrors the new split API (check pure, commit_pause + rebase explicit).
|
||
"""
|
||
|
||
def __init__(self, drift_distance: int = 0):
|
||
self.drift_distance = drift_distance
|
||
self.is_paused = False
|
||
self.commit_pause_calls: list[int] = []
|
||
self.rebase_calls: list[str] = []
|
||
self.resume_calls = 0
|
||
|
||
def check(self, _frame):
|
||
return types.SimpleNamespace(
|
||
distance=self.drift_distance,
|
||
drifted=self.drift_distance > 0,
|
||
paused=self.is_paused,
|
||
)
|
||
|
||
def commit_pause(self, distance: int) -> None:
|
||
if self.is_paused:
|
||
return
|
||
self.is_paused = True
|
||
self.commit_pause_calls.append(distance)
|
||
|
||
def rebase(self, new_phash: str) -> None:
|
||
self.rebase_calls.append(new_phash)
|
||
|
||
def resume(self) -> None:
|
||
self.is_paused = False
|
||
self.resume_calls += 1
|
||
|
||
|
||
def _build_multi_tick_ctx(tmp_path, cfg, canary, initial_strips):
|
||
"""Construct a RunContext suitable for driving _run_multi_tick.
|
||
|
||
Returns (ctx, notifier_alerts, audit_events, detector_stubs).
|
||
Charts are seeded with stub Detectors that record dot_roi updates.
|
||
"""
|
||
import atm.main as _main
|
||
from atm.detector import DetectionResult
|
||
|
||
notifier_alerts: list = []
|
||
audit_events: list = []
|
||
|
||
class _Det:
|
||
def __init__(self):
|
||
self.last_roi = None
|
||
|
||
def step(self, ts, frame=None):
|
||
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 = {}
|
||
|
||
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
|
||
|
||
state_obj = _main._LoopState()
|
||
detectors = [_Det() for _ in initial_strips]
|
||
charts = [
|
||
_main.ChartState(
|
||
chart_id=("left" if i == 0 else "right") if len(initial_strips) == 2 else "only",
|
||
sub_roi=strip, detector=detectors[i], fsm=_FSM(),
|
||
)
|
||
for i, strip in enumerate(initial_strips)
|
||
]
|
||
ctx = _main.RunContext(
|
||
cfg=cfg, capture=lambda: np.zeros((200, 1800, 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,
|
||
)
|
||
ctx.charts = charts
|
||
return ctx, notifier_alerts, audit_events, detectors
|
||
|
||
|
||
def _make_minimal_cfg_for_multi_tick():
|
||
"""Minimal cfg that satisfies _run_multi_tick + _commit_layout_change paths."""
|
||
from atm.config import ROI
|
||
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)
|
||
cfg.window_title = None
|
||
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.config_version = "test-cfg"
|
||
cfg.canary = types.SimpleNamespace(
|
||
roi=ROI(x=0, y=0, w=50, h=50),
|
||
baseline_phash="0" * 64,
|
||
drift_threshold=8,
|
||
)
|
||
return cfg
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_drift_with_strip_count_change_auto_rebases(monkeypatch, tmp_path):
|
||
"""Drift + strip count 2→1 → silent auto-rebase, no pause, single combined alert."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=156)
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
new_strips = [ROI(x=0, y=0, w=900, h=35)] # 2 → 1
|
||
ctx, notifier_alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: new_strips)
|
||
rewrite_calls: list = []
|
||
def _stub_rewrite(path, new_phash):
|
||
rewrite_calls.append((path, new_phash))
|
||
return "OLD_PHASH"
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash", _stub_rewrite)
|
||
|
||
await _main._run_multi_tick(ctx)
|
||
|
||
# Layout committed: charts shrank to 1.
|
||
assert len(ctx.charts) == 1
|
||
assert ctx.charts[0].sub_roi == new_strips[0]
|
||
# Canary NOT paused (auto-rebase path).
|
||
assert canary.is_paused is False
|
||
assert canary.commit_pause_calls == []
|
||
# rebase() called with the new phash that was also passed to _rewrite_baseline_phash.
|
||
assert len(canary.rebase_calls) == 1
|
||
assert len(rewrite_calls) == 1
|
||
new_phash = canary.rebase_calls[0]
|
||
assert rewrite_calls[0][1] == new_phash
|
||
# Audit: layout_change_with_rebase + standard layout_change (from _commit_layout_change).
|
||
rebase_events = [e for e in audit_events if e.get("event") == "layout_change_with_rebase"]
|
||
assert len(rebase_events) == 1
|
||
assert rebase_events[0]["old_n"] == 2
|
||
assert rebase_events[0]["new_n"] == 1
|
||
assert rebase_events[0]["distance"] == 156
|
||
assert rebase_events[0]["old_phash"] == "OLD_PHASH"
|
||
assert rebase_events[0]["new_phash"] == new_phash
|
||
# No "paused" audit event.
|
||
assert not any(e.get("event") == "paused" for e in audit_events)
|
||
# Exactly one combined alert. Generic layout_change alert was suppressed.
|
||
combined = [a for a in notifier_alerts if "auto-rebased" in a.title]
|
||
assert len(combined) == 1
|
||
assert "2→1" in combined[0].title
|
||
plain_layout = [a for a in notifier_alerts
|
||
if "Layout TS schimbat" in a.title and "auto-rebased" not in a.title]
|
||
assert plain_layout == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_drift_with_same_strip_count_pauses(monkeypatch, tmp_path):
|
||
"""Drift without strip count change → real drift → pause as before."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=156)
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
same_count = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
ctx, notifier_alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: same_count)
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash",
|
||
lambda *a, **kw: pytest.fail("should not be called"))
|
||
|
||
results = await _main._run_multi_tick(ctx)
|
||
|
||
assert results == []
|
||
assert canary.is_paused is True
|
||
assert canary.commit_pause_calls == [156]
|
||
assert len(ctx.charts) == 2 # unchanged
|
||
paused_events = [e for e in audit_events if e.get("event") == "paused"]
|
||
assert len(paused_events) == 1
|
||
assert paused_events[0]["drift"] == 156
|
||
assert canary.rebase_calls == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_drift_with_zero_strips_pauses(monkeypatch, tmp_path):
|
||
"""Strip detection returns [] (chart blackout) → pause, do NOT auto-rebase."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=156)
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
ctx, _alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: [])
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash",
|
||
lambda *a, **kw: pytest.fail("should not be called"))
|
||
|
||
results = await _main._run_multi_tick(ctx)
|
||
|
||
assert results == []
|
||
assert canary.is_paused is True
|
||
assert canary.commit_pause_calls == [156]
|
||
assert canary.rebase_calls == []
|
||
assert any(e.get("event") == "paused" for e in audit_events)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_no_drift_strip_count_change_unchanged_path(monkeypatch, tmp_path):
|
||
"""Strip count change without drift → existing _commit_layout_change path,
|
||
canary baseline untouched, silent layout-change alert."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=0) # no drift
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
new_strips = [ROI(x=0, y=0, w=900, h=35)]
|
||
ctx, notifier_alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: new_strips)
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash",
|
||
lambda *a, **kw: pytest.fail("should not be called"))
|
||
|
||
await _main._run_multi_tick(ctx)
|
||
|
||
assert len(ctx.charts) == 1
|
||
assert canary.rebase_calls == []
|
||
# Standard layout_change alert (not suppressed).
|
||
layout_alerts = [a for a in notifier_alerts if "Layout TS schimbat" in a.title]
|
||
assert len(layout_alerts) == 1
|
||
assert "auto-rebased" not in layout_alerts[0].title
|
||
assert any(e.get("event") == "layout_change" for e in audit_events)
|
||
assert not any(e.get("event") == "layout_change_with_rebase" for e in audit_events)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_auto_rebase_toml_failure_falls_back_to_pause(monkeypatch, tmp_path):
|
||
"""When _rewrite_baseline_phash raises, fall back to commit_pause + audit."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=156)
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
new_strips = [ROI(x=0, y=0, w=900, h=35)]
|
||
ctx, notifier_alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: new_strips)
|
||
def _boom(_path, _new):
|
||
raise ValueError("expected exactly 1 baseline_phash line, found 0")
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash", _boom)
|
||
|
||
results = await _main._run_multi_tick(ctx)
|
||
|
||
assert results == []
|
||
assert canary.is_paused is True
|
||
assert canary.commit_pause_calls == [156]
|
||
# Charts NOT mutated.
|
||
assert len(ctx.charts) == 2
|
||
fail_events = [e for e in audit_events if e.get("event") == "auto_rebase_failed"]
|
||
assert len(fail_events) == 1
|
||
paused_events = [e for e in audit_events
|
||
if e.get("event") == "paused" and e.get("reason") == "auto_rebase_failed"]
|
||
assert len(paused_events) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_symmetric_1_to_2(monkeypatch, tmp_path):
|
||
"""1→2 transition is symmetric: same auto-rebase + layout commit path."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=120)
|
||
initial = [ROI(x=0, y=0, w=900, h=35)]
|
||
new_strips = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
ctx, notifier_alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", lambda c, f: new_strips)
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash",
|
||
lambda path, new_phash: "OLD")
|
||
|
||
await _main._run_multi_tick(ctx)
|
||
|
||
assert len(ctx.charts) == 2
|
||
assert canary.is_paused is False
|
||
assert len(canary.rebase_calls) == 1
|
||
rebase_events = [e for e in audit_events if e.get("event") == "layout_change_with_rebase"]
|
||
assert len(rebase_events) == 1
|
||
assert rebase_events[0]["old_n"] == 1
|
||
assert rebase_events[0]["new_n"] == 2
|
||
combined = [a for a in notifier_alerts if "auto-rebased" in a.title]
|
||
assert len(combined) == 1
|
||
assert "1→2" in combined[0].title
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_multi_tick_already_paused_short_circuits(monkeypatch, tmp_path):
|
||
"""is_paused=True at tick start → return [], no auto-rebase attempted."""
|
||
import atm.main as _main
|
||
from atm.config import ROI
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
cfg = _make_minimal_cfg_for_multi_tick()
|
||
canary = _FakeCanary(drift_distance=156)
|
||
canary.is_paused = True # already paused from a prior tick
|
||
initial = [ROI(x=0, y=0, w=400, h=35), ROI(x=500, y=0, w=400, h=35)]
|
||
new_strips = [ROI(x=0, y=0, w=900, h=35)]
|
||
ctx, _alerts, audit_events, _ = _build_multi_tick_ctx(
|
||
tmp_path, cfg, canary, initial,
|
||
)
|
||
|
||
# Strip detection should NOT run on the already-paused short-circuit path.
|
||
detect_calls: list = []
|
||
def _detect(c, f):
|
||
detect_calls.append((c, f))
|
||
return new_strips
|
||
monkeypatch.setattr(_main, "_detect_strips_for_ctx", _detect)
|
||
monkeypatch.setattr(_main, "_rewrite_baseline_phash",
|
||
lambda *a, **kw: pytest.fail("should not be called"))
|
||
|
||
results = await _main._run_multi_tick(ctx)
|
||
|
||
assert results == []
|
||
assert detect_calls == [] # short-circuited before strip detection
|
||
assert canary.rebase_calls == []
|
||
assert canary.commit_pause_calls == [] # already paused; no new commit
|
||
assert len(ctx.charts) == 2
|
||
assert any(e.get("event") == "paused" for e in audit_events)
|