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:
2026-04-22 22:56:51 +03:00
parent 45ed502b3d
commit 248ad6b10e
5 changed files with 465 additions and 2 deletions

View File

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

View File

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