feat(telegram): /rebase + /rebase confirm pentru re-anchor canary baseline
/rebase capturează + propune phash nou (screenshot adnotat cu red rect pe canary.roi, old/new hash, distance, TTL 180s). /rebase confirm rescrie baseline_phash în TOML-ul activ (regex line-match, păstrează comentariile), mirror în cfg live via object.__setattr__ (CanaryRegion e frozen), clear user_paused + drift_paused într-un singur shot — similar /resume. Fix adiacent: _dispatch_ctx / _mock_config_class setează cfg.window_title=None explicit; 5 teste _dispatch_command pre-existente eșuau pe MagicMock auto- truthy care propaga în _focus_window_by_title. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,20 @@ def test_parse_resume_force():
|
||||
assert cmd.value == 1
|
||||
|
||||
|
||||
def test_parse_rebase_plain():
|
||||
p = _make_poller()
|
||||
assert p._parse_command("rebase") == Command(action="rebase")
|
||||
assert p._parse_command("/rebase") == Command(action="rebase")
|
||||
|
||||
|
||||
def test_parse_rebase_confirm():
|
||||
p = _make_poller()
|
||||
cmd = p._parse_command("rebase confirm")
|
||||
assert cmd is not None
|
||||
assert cmd.action == "rebase"
|
||||
assert cmd.value == 1
|
||||
|
||||
|
||||
def test_parse_help():
|
||||
p = _make_poller()
|
||||
assert p._parse_command("h") == Command(action="help")
|
||||
|
||||
@@ -30,6 +30,9 @@ 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
|
||||
@@ -783,6 +786,9 @@ def _dispatch_ctx(canary=None, lifecycle=None, cfg=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(
|
||||
@@ -896,6 +902,234 @@ async def test_resume_force_alias_still_works():
|
||||
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."""
|
||||
@@ -1214,6 +1448,7 @@ async def test_lifecycle_with_drift_then_resume_then_fire(monkeypatch, tmp_path)
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user