visual-cortex 0.9.0

Watch screen regions and receive typed events over async streams when patterns match: OCR, template matching, and pixel conditions on live captures.
//! End-to-end: StabilityMask isolates a newly-appeared tooltip from noisy
//! gameplay and a permanent HUD, and the masked (static) output holds the
//! frame-diff gate.

use std::time::Duration;

use visual_cortex::{
    Brightness, EventKind, FakeSource, Frame, Rate, Region, Session, StabilityMask,
};

/// 24x24 scene on an 8px block grid (3x3 blocks):
/// - block (0,0) is "gameplay noise" — luma alternates wildly every tick;
/// - block (2,2) is a "HUD element" — constant bright since frame zero;
/// - block (1,1) is the "tooltip" — background until it appears, then a
///   constant bright overlay;
/// - everything else is a flat 60-luma background.
fn scene(noise: u8, tooltip: bool) -> Frame {
    const W: usize = 24;
    let mut data = vec![0u8; W * W * 4];
    for y in 0..W {
        for x in 0..W {
            let lum = if x < 8 && y < 8 {
                noise
            } else if x >= 16 && y >= 16 {
                220
            } else if (8..16).contains(&x) && (8..16).contains(&y) && tooltip {
                230
            } else {
                60
            };
            let px = (y * W + x) * 4;
            data[px] = lum;
            data[px + 1] = lum;
            data[px + 2] = lum;
            data[px + 3] = 255;
        }
    }
    Frame::new(24, 24, data).unwrap()
}

#[tokio::test(start_paused = true)]
async fn mask_isolates_tooltip_and_holds_the_diff_gate() {
    // 10 ticks without the tooltip (baseline settles), then it appears and
    // is held. Noise churns the whole run.
    let frames: Vec<Frame> = (0..80)
        .map(|i| scene(if i % 2 == 0 { 40 } else { 200 }, i >= 10))
        .collect();
    let session = Session::builder()
        .source(FakeSource::new(frames))
        .build()
        .await
        .unwrap();

    let mask = StabilityMask::new()
        .block_size(8)
        .stable_ticks(3)
        .baseline_ticks(20)
        .dilate(0);
    let mut stream = session
        .watch("tooltip")
        .region(Region::Full)
        .rate(Rate::hz(20.0))
        .preprocess(mask)
        .detector(Brightness)
        .subscribe()
        .unwrap();

    // First evaluation: everything suppressed (cold start + noise never
    // stable + HUD/background never novel) — brightness is zero.
    let ev = tokio::time::timeout(Duration::from_secs(10), stream.next())
        .await
        .expect("initial event")
        .expect("stream open");
    let EventKind::Changed { new, .. } = &ev.kind else {
        panic!("expected Changed, got {:?}", ev.kind);
    };
    assert!(
        new.as_number().unwrap() < 1.0,
        "cold-start mask must be fully black, got {new:?}"
    );

    // Despite raw frames churning every tick (noise block), the masked
    // output stays black until the tooltip becomes stable — the next event
    // is the tooltip block turning on.
    let ev = tokio::time::timeout(Duration::from_secs(10), stream.next())
        .await
        .expect("tooltip event")
        .expect("stream open");
    let EventKind::Changed { new, .. } = &ev.kind else {
        panic!("expected Changed, got {:?}", ev.kind);
    };
    let brightness = new.as_number().unwrap();
    assert!(
        brightness > 10.0,
        "tooltip block must pass through once stable, got {brightness}"
    );
    // Only the tooltip block (64 of 576 px at luma 230 ≈ 25.5 mean) — if
    // noise or HUD leaked through this would be far higher.
    assert!(
        brightness < 60.0,
        "noise/HUD must remain suppressed, got {brightness}"
    );

    // Held tooltip: masked output is static again, so the diff gate holds
    // and no further events fire.
    let quiet = tokio::time::timeout(Duration::from_secs(2), stream.next()).await;
    assert!(
        quiet.is_err(),
        "no further events while the tooltip is held: {quiet:?}"
    );
}