"""Tests for src/atm/canary.py.""" from __future__ import annotations import dataclasses from pathlib import Path import numpy as np import pytest from atm.canary import Canary, CanaryResult from atm.config import ( CanaryRegion, ColorSpec, Config, DiscordCfg, ROI, TelegramCfg, YAxisCalib, ) from atm.vision import crop_roi, phash # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- CANARY_ROI = ROI(x=0, y=0, w=50, h=50) def _make_base_cfg() -> Config: colors = { "turquoise": ColorSpec(rgb=(0, 255, 255), tolerance=30.0), "yellow": ColorSpec(rgb=(255, 255, 0), tolerance=30.0), "dark_green": ColorSpec(rgb=(0, 100, 0), tolerance=30.0), "dark_red": ColorSpec(rgb=(100, 0, 0), tolerance=30.0), "light_green": ColorSpec(rgb=(0, 255, 0), tolerance=30.0), "light_red": ColorSpec(rgb=(255, 0, 0), tolerance=30.0), "gray": ColorSpec(rgb=(128, 128, 128), tolerance=30.0), } # placeholder baseline_phash; tests replace canary via dataclasses.replace return Config( window_title="test", dot_roi=ROI(x=0, y=0, w=100, h=50), chart_roi=ROI(x=0, y=0, w=600, h=400), colors=colors, y_axis=YAxisCalib(p1_y=0, p1_price=100.0, p2_y=400, p2_price=80.0), canary=CanaryRegion(roi=CANARY_ROI, baseline_phash="0" * 64, drift_threshold=8), discord=DiscordCfg(webhook_url="http://example.com/hook"), telegram=TelegramCfg(bot_token="tok", chat_id="123"), ) def _cfg_with_baseline(baseline_frame: np.ndarray) -> Config: """Build a Config whose baseline_phash matches the given frame's canary ROI.""" roi_img = crop_roi(baseline_frame, CANARY_ROI) h = phash(roi_img) canary_region = CanaryRegion(roi=CANARY_ROI, baseline_phash=h, drift_threshold=8) return dataclasses.replace(_make_base_cfg(), canary=canary_region) def _checkerboard(h: int, w: int, block: int = 8) -> np.ndarray: """Return a checkerboard BGR image (high-frequency, distinct phash).""" img = np.zeros((h, w, 3), dtype=np.uint8) for y in range(0, h, block): for x in range(0, w, block): if (y // block + x // block) % 2 == 0: img[y : y + block, x : x + block] = 255 return img # A purely black 100×100 frame as baseline BASELINE_FRAME = np.zeros((100, 100, 3), dtype=np.uint8) # A frame where the canary ROI is a checkerboard (visually very different) DRIFTED_FRAME = BASELINE_FRAME.copy() DRIFTED_FRAME[:50, :50] = _checkerboard(50, 50) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_no_drift() -> None: """Same image as baseline → distance ≤ threshold, not paused.""" cfg = _cfg_with_baseline(BASELINE_FRAME) canary = Canary(cfg) result = canary.check(BASELINE_FRAME) assert result.drifted is False assert result.paused is False assert canary.is_paused is False 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 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 commit_pause, feeding back a clean frame keeps paused=True.""" cfg = _cfg_with_baseline(BASELINE_FRAME) canary = Canary(cfg) 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 assert canary.is_paused is True def test_resume_clears() -> None: """resume() clears the paused flag; subsequent clean frame stays unpaused.""" cfg = _cfg_with_baseline(BASELINE_FRAME) canary = Canary(cfg) r = canary.check(DRIFTED_FRAME) canary.commit_pause(r.distance) canary.resume() assert canary.is_paused is False result = canary.check(BASELINE_FRAME) assert result.paused is False def test_pause_file_written(tmp_path: Path) -> None: """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() r = canary.check(DRIFTED_FRAME) assert not flag.exists() # check() alone does NOT write the flag canary.commit_pause(r.distance) assert flag.exists() def test_canary_pause_callback_fires_once() -> None: """Single-shot: callback invoked exactly once per not_paused→paused edge.""" cfg = _cfg_with_baseline(BASELINE_FRAME) calls: list[int] = [] canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d)) 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) calls: list[int] = [] canary = Canary(cfg, on_pause_callback=lambda d: calls.append(d)) r1 = canary.check(DRIFTED_FRAME) canary.commit_pause(r1.distance) assert len(calls) == 1 canary.resume() 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_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: raise RuntimeError("notifier down") canary = Canary(cfg, on_pause_callback=_boom) # Must not raise — exception is swallowed + logged. r = canary.check(DRIFTED_FRAME) canary.commit_pause(r.distance) assert canary.is_paused is True def test_resume_deletes_pause_file(tmp_path: Path) -> None: """resume() deletes the pause flag file.""" flag = tmp_path / "paused.flag" cfg = _cfg_with_baseline(BASELINE_FRAME) canary = Canary(cfg, pause_flag_path=flag) 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