feat(canary): auto-rebase pe layout change (2↔1 ferestre)
Când canary drift coincide cu schimbare strip-count pe același frame (ex: TS comută 2→1 chart-uri și mută menu-ul peste care e ancorat ROI), sistemul rescrie automat baseline_phash în TOML, commit layout change și trimite o singură alertă combinată — fără pauză, fără /rebase manual. Drift fără strip-count change rămâne pauză ca azi (drift real). Gate pe două semnale independente previne fals-pozitive. Canary.check() despărțit în măsurare pură + commit_pause/rebase explicit; tick-loop-ul orchestrează decizia. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,24 +91,37 @@ def test_no_drift() -> None:
|
||||
assert canary.is_paused is False
|
||||
|
||||
|
||||
def test_drift_triggers_pause() -> None:
|
||||
"""Drastically different canary ROI → drifted=True, paused=True."""
|
||||
def test_check_does_not_auto_pause() -> None:
|
||||
"""check() is pure measurement — never transitions to paused on its own."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg)
|
||||
|
||||
result = canary.check(DRIFTED_FRAME)
|
||||
|
||||
assert result.drifted is True
|
||||
assert result.paused is True
|
||||
assert result.paused is False # not committed
|
||||
assert canary.is_paused is False
|
||||
|
||||
|
||||
def test_drift_triggers_pause() -> None:
|
||||
"""check() detects drift; commit_pause() transitions state."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg)
|
||||
|
||||
result = canary.check(DRIFTED_FRAME)
|
||||
assert result.drifted is True
|
||||
canary.commit_pause(result.distance)
|
||||
|
||||
assert canary.is_paused is True
|
||||
|
||||
|
||||
def test_persists_paused() -> None:
|
||||
"""After drift, feeding back a clean frame keeps paused=True."""
|
||||
"""After commit_pause, feeding back a clean frame keeps paused=True."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg)
|
||||
|
||||
canary.check(DRIFTED_FRAME) # trigger pause
|
||||
r1 = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r1.distance)
|
||||
result = canary.check(BASELINE_FRAME) # clean frame, but still paused
|
||||
|
||||
assert result.paused is True
|
||||
@@ -120,7 +133,8 @@ def test_resume_clears() -> None:
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg)
|
||||
|
||||
canary.check(DRIFTED_FRAME) # pause
|
||||
r = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r.distance)
|
||||
canary.resume()
|
||||
|
||||
assert canary.is_paused is False
|
||||
@@ -130,13 +144,15 @@ def test_resume_clears() -> None:
|
||||
|
||||
|
||||
def test_pause_file_written(tmp_path: Path) -> None:
|
||||
"""When pause_flag_path is provided, the file is created on drift."""
|
||||
"""When pause_flag_path is provided, the file is created on commit_pause."""
|
||||
flag = tmp_path / "paused.flag"
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg, pause_flag_path=flag)
|
||||
|
||||
assert not flag.exists()
|
||||
canary.check(DRIFTED_FRAME)
|
||||
r = canary.check(DRIFTED_FRAME)
|
||||
assert not flag.exists() # check() alone does NOT write the flag
|
||||
canary.commit_pause(r.distance)
|
||||
assert flag.exists()
|
||||
|
||||
|
||||
@@ -147,14 +163,32 @@ def test_canary_pause_callback_fires_once() -> None:
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
|
||||
|
||||
canary.check(DRIFTED_FRAME) # transition → callback fires
|
||||
canary.check(DRIFTED_FRAME) # still paused → no new callback
|
||||
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
|
||||
r1 = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r1.distance) # transition → callback fires
|
||||
canary.commit_pause(r1.distance) # idempotent → no new callback
|
||||
r2 = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r2.distance) # still paused → no new callback
|
||||
canary.check(BASELINE_FRAME) # clean but still paused → no new callback
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0] > 0 # distance should be positive
|
||||
|
||||
|
||||
def test_commit_pause_idempotent() -> None:
|
||||
"""commit_pause is no-op when already paused — no flag re-write, no callback."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
calls: list[int] = []
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
|
||||
|
||||
canary.commit_pause(100)
|
||||
canary.commit_pause(200)
|
||||
canary.commit_pause(300)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == 100
|
||||
|
||||
|
||||
def test_canary_resume_allows_new_pause_notification() -> None:
|
||||
"""After resume, a fresh drift must re-fire the callback."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
@@ -162,17 +196,19 @@ def test_canary_resume_allows_new_pause_notification() -> None:
|
||||
|
||||
canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d))
|
||||
|
||||
canary.check(DRIFTED_FRAME)
|
||||
r1 = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r1.distance)
|
||||
assert len(calls) == 1
|
||||
|
||||
canary.resume()
|
||||
canary.check(DRIFTED_FRAME) # new pause transition
|
||||
r2 = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r2.distance) # new pause transition
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_canary_pause_callback_exception_does_not_crash_check() -> None:
|
||||
"""A failing callback must not break canary.check (detection cycle safety)."""
|
||||
def test_canary_pause_callback_exception_does_not_crash_commit_pause() -> None:
|
||||
"""A failing callback must not break commit_pause (detection cycle safety)."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
|
||||
def _boom(_d: int) -> None:
|
||||
@@ -181,8 +217,8 @@ def test_canary_pause_callback_exception_does_not_crash_check() -> None:
|
||||
canary = Canary(cfg, on_pause_callback=_boom)
|
||||
|
||||
# Must not raise — exception is swallowed + logged.
|
||||
result = canary.check(DRIFTED_FRAME)
|
||||
assert result.paused is True
|
||||
r = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r.distance)
|
||||
assert canary.is_paused is True
|
||||
|
||||
|
||||
@@ -192,7 +228,26 @@ def test_resume_deletes_pause_file(tmp_path: Path) -> None:
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg, pause_flag_path=flag)
|
||||
|
||||
canary.check(DRIFTED_FRAME)
|
||||
r = canary.check(DRIFTED_FRAME)
|
||||
canary.commit_pause(r.distance)
|
||||
assert flag.exists()
|
||||
canary.resume()
|
||||
assert not flag.exists()
|
||||
|
||||
|
||||
def test_rebase_updates_baseline_in_memory() -> None:
|
||||
"""rebase(new_h) mirrors hash into cfg.canary; subsequent check uses it."""
|
||||
cfg = _cfg_with_baseline(BASELINE_FRAME)
|
||||
canary = Canary(cfg)
|
||||
|
||||
# Compute the phash of the drifted frame; rebase to it.
|
||||
drifted_hash = phash(crop_roi(DRIFTED_FRAME, CANARY_ROI))
|
||||
assert cfg.canary.baseline_phash != drifted_hash
|
||||
|
||||
canary.rebase(drifted_hash)
|
||||
|
||||
assert cfg.canary.baseline_phash == drifted_hash
|
||||
# Now the drifted frame reads as clean.
|
||||
result = canary.check(DRIFTED_FRAME)
|
||||
assert result.drifted is False
|
||||
assert result.paused is False
|
||||
|
||||
Reference in New Issue
Block a user