visual-cortex 0.10.0

Watch screen regions and receive typed events over async streams when patterns match: OCR, template matching, and pixel conditions on live captures.
//! v2 end-to-end: browsing hand-off stays live (no patchwork blackout) and
//! whole-scene changes re-seed instead of ratcheting into the kept set.

use std::time::Duration;

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

const W: usize = 24; // 3x3 grid of 8px blocks

/// Flat background with an optional tooltip block at (1,1).
fn scene(bg: u8, tooltip: Option<u8>) -> Frame {
    Frame::from_fn(W as u32, W as u32, |x, y| {
        let lum = match tooltip {
            Some(t) if (8..16).contains(&x) && (8..16).contains(&y) => t,
            _ => bg,
        };
        [lum, lum, lum, 255]
    })
}

fn mask() -> StabilityMask {
    StabilityMask::new()
        .block_size(8)
        .stable_ticks(3)
        .baseline_ticks(20)
        .min_component(1)
        .dilate(0)
}

async fn drain_brightness(stream: &mut visual_cortex::EventStream, secs: u64) -> Vec<f64> {
    let mut vals = Vec::new();
    loop {
        match tokio::time::timeout(Duration::from_secs(secs), stream.next()).await {
            Ok(Some(ev)) => {
                if let EventKind::Changed { new, .. } = &ev.kind {
                    vals.push(new.as_number().unwrap());
                }
            }
            _ => return vals,
        }
    }
}

#[tokio::test(start_paused = true)]
async fn browsing_hand_off_keeps_the_popup_live() {
    // Background settles; tooltip A appears and holds; A switches to B
    // (brighter); B is dismissed. The masked output must show A, hand off
    // to B without ever going dark in between, and only go black after
    // dismissal.
    let mut frames = Vec::new();
    frames.extend((0..10).map(|_| scene(60, None)));
    frames.extend((0..15).map(|_| scene(60, Some(200)))); // A held
    frames.extend((0..15).map(|_| scene(60, Some(240)))); // B held
    frames.extend((0..20).map(|_| scene(60, None))); // dismissed

    let session = Session::builder()
        .source(FakeSource::new(frames))
        .build()
        .await
        .unwrap();
    let mut stream = session
        .watch("browse")
        .region(Region::Full)
        .rate(Rate::hz(20.0))
        .preprocess(mask())
        .detector(Brightness)
        .subscribe()
        .unwrap();

    let vals = drain_brightness(&mut stream, 5).await;
    assert!(vals.len() >= 3, "expected a rich event sequence: {vals:?}");
    assert!(vals[0] < 1.0, "cold start is black: {vals:?}");

    // A kept: one 8x8 block of luma 200 in a 24x24 frame ≈ 22 mean.
    let a_idx = vals.iter().position(|v| *v > 15.0).expect("A revealed");
    // B kept: luma 240 block ≈ 26.7 mean.
    let b_idx = vals.iter().position(|v| *v > 24.0).expect("B revealed");
    assert!(b_idx >= a_idx, "A before B");
    assert!(
        vals[a_idx..=b_idx].iter().all(|v| *v > 15.0),
        "hand-off must never go dark between A and B (v1 patchwork): {vals:?}"
    );
    assert!(
        vals.last().unwrap() < &1.0,
        "dismissal ends black: {vals:?}"
    );
}

#[tokio::test(start_paused = true)]
async fn scene_change_reseeds_and_nothing_is_kept() {
    // Settled scene, then every block churns for a while (walking), then a
    // NEW scene settles. v1 ratcheted the new scene into the kept set; v2
    // must re-seed and stay fully suppressed (masked output never changes
    // after the initial black frame).
    let mut frames = Vec::new();
    frames.extend((0..8).map(|_| scene(60, None)));
    frames.extend((0..14).map(|i| scene(if i % 2 == 0 { 90 } else { 180 }, None)));
    frames.extend((0..40).map(|_| scene(140, None))); // new scene, settled

    let session = Session::builder()
        .source(FakeSource::new(frames))
        .build()
        .await
        .unwrap();
    let mut stream = session
        .watch("walk")
        .region(Region::Full)
        .rate(Rate::hz(20.0))
        .preprocess(mask())
        .detector(Brightness)
        .subscribe()
        .unwrap();

    let vals = drain_brightness(&mut stream, 4).await;
    assert!(!vals.is_empty(), "initial event expected");
    assert!(vals[0] < 1.0, "cold start black");
    assert!(
        vals.iter().all(|v| *v < 1.0),
        "new scene must re-seed, never be kept: {vals:?}"
    );
}