use std::collections::{HashSet, VecDeque};
use std::time::Duration;
use visual_cortex::{
from_fn, DetectorError, DetectorOutput, Error, EventKind, FakeSource, Frame, Rate, Region,
Session, ThresholdDirection,
};
async fn next_event(stream: &mut visual_cortex::EventStream) -> visual_cortex::Event {
tokio::time::timeout(Duration::from_secs(30), stream.next())
.await
.expect("timed out waiting for event")
.expect("stream closed unexpectedly")
}
#[tokio::test(start_paused = true)]
async fn every_match_mode_emits_per_tick() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(10, 10, [0, 0, 200, 255])]))
.build()
.await
.unwrap();
let mut stream = session
.watch("always")
.region(Region::Full)
.rate(Rate::hz(20.0))
.frame_diff(false) .detector(from_fn(|_| Ok(DetectorOutput::Bool(true))))
.on_every_match()
.subscribe()
.unwrap();
for _ in 0..3 {
let ev = next_event(&mut stream).await;
assert_eq!(&*ev.watcher, "always");
assert!(matches!(
ev.kind,
EventKind::Matched {
output: DetectorOutput::Bool(true)
}
));
}
}
#[tokio::test(start_paused = true)]
async fn gates_block_the_detector() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(10, 10, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let mut stream = session
.watch("gated")
.rate(Rate::hz(20.0))
.frame_diff(false)
.gate(from_fn(|_| Ok(DetectorOutput::Bool(false)))) .detector(from_fn(|_| Ok(DetectorOutput::Bool(true))))
.on_every_match()
.subscribe()
.unwrap();
let got = tokio::time::timeout(Duration::from_millis(500), stream.next()).await;
assert!(got.is_err(), "closed gate must suppress all events");
}
#[tokio::test]
async fn duplicate_watcher_name_errors() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(2, 2, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let _first = session
.watch("hp")
.detector(from_fn(|_| Ok(DetectorOutput::None)))
.subscribe()
.unwrap();
let err = session
.watch("hp")
.detector(from_fn(|_| Ok(DetectorOutput::None)))
.subscribe()
.unwrap_err();
assert!(matches!(err, Error::DuplicateWatcher(name) if name == "hp"));
}
#[tokio::test]
async fn missing_detector_errors() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(2, 2, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let err = session.watch("empty").subscribe().unwrap_err();
assert!(matches!(err, Error::MissingDetector(name) if name == "empty"));
}
#[tokio::test(start_paused = true)]
async fn dropping_the_session_ends_event_streams() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(4, 4, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let mut stream = session
.watch("doomed")
.rate(Rate::hz(20.0))
.frame_diff(false)
.detector(from_fn(|_| Ok(DetectorOutput::Bool(true))))
.on_every_match()
.subscribe()
.unwrap();
tokio::time::timeout(Duration::from_secs(30), stream.next())
.await
.expect("watcher should emit while session is alive")
.expect("stream closed prematurely");
drop(session);
let end = tokio::time::timeout(Duration::from_secs(30), stream.next())
.await
.expect("stream did not end after session drop");
assert!(
end.is_none(),
"expected None after session drop, got {end:?}"
);
}
#[tokio::test(start_paused = true)]
async fn change_mode_emits_only_on_transitions() {
let mut frames = vec![Frame::solid(10, 10, [10, 10, 10, 255]); 5];
frames.push(Frame::solid(10, 10, [10, 10, 250, 255]));
let session = Session::builder()
.source(FakeSource::new(frames))
.build()
.await
.unwrap();
let mut stream = session
.watch("red_banner")
.region(Region::Full)
.rate(Rate::hz(20.0))
.detector(from_fn(|view: &visual_cortex::FrameView<'_>| {
let first_row = view.rows().next().expect("view is non-empty");
Ok(DetectorOutput::Bool(first_row[2] > 128)) }))
.subscribe()
.unwrap();
let first = next_event(&mut stream).await;
assert!(
matches!(
&first.kind,
EventKind::Changed {
old: None,
new: DetectorOutput::Bool(false)
}
),
"first evaluation reports the initial state, got {:?}",
first.kind
);
let second = next_event(&mut stream).await;
assert!(
matches!(
&second.kind,
EventKind::Changed {
old: Some(DetectorOutput::Bool(false)),
new: DetectorOutput::Bool(true)
}
),
"transition to red fires exactly one Changed event, got {:?}",
second.kind
);
let more = tokio::time::timeout(Duration::from_millis(500), stream.next()).await;
assert!(
more.is_err(),
"Change mode must not emit without a transition"
);
}
#[tokio::test(start_paused = true)]
async fn threshold_mode_fires_on_predicate_flips() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(10, 10, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let mut values = VecDeque::from([200.0, 150.0, 80.0, 60.0, 120.0]);
let mut stream = session
.watch("hp")
.rate(Rate::hz(20.0))
.frame_diff(false) .detector(from_fn(move |_| {
let v = values.pop_front().unwrap_or(120.0);
Ok(DetectorOutput::Number(v))
}))
.on_threshold(|hp| hp < 100.0)
.subscribe()
.unwrap();
let entered = next_event(&mut stream).await;
assert!(
matches!(
entered.kind,
EventKind::ThresholdCrossed { value, direction: ThresholdDirection::Entered } if value == 80.0
),
"expected Entered at 80, got {:?}",
entered.kind
);
let exited = next_event(&mut stream).await;
assert!(
matches!(
exited.kind,
EventKind::ThresholdCrossed { value, direction: ThresholdDirection::Exited } if value == 120.0
),
"expected Exited at 120, got {:?}",
exited.kind
);
let more = tokio::time::timeout(Duration::from_millis(500), stream.next()).await;
assert!(
more.is_err(),
"threshold mode must not emit without a predicate flip"
);
}
#[tokio::test(start_paused = true)]
async fn repeated_detector_errors_emit_watcher_degraded_once() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(10, 10, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let mut stream = session
.watch("broken")
.rate(Rate::hz(20.0))
.frame_diff(false) .detector(from_fn(|_| Err(DetectorError::Other("boom".into()))))
.on_every_match()
.subscribe()
.unwrap();
let ev = next_event(&mut stream).await;
assert!(
matches!(
ev.kind,
EventKind::WatcherDegraded {
consecutive_errors: 5
}
),
"expected WatcherDegraded after 5 consecutive errors, got {:?}",
ev.kind
);
let more = tokio::time::timeout(Duration::from_millis(500), stream.next()).await;
assert!(
more.is_err(),
"degraded event must not repeat while errors continue"
);
}
#[tokio::test(start_paused = true)]
async fn merged_streams_deliver_events_from_both_watchers() {
let session = Session::builder()
.source(FakeSource::new([Frame::solid(10, 10, [0, 0, 0, 255])]))
.build()
.await
.unwrap();
let a = session
.watch("a")
.rate(Rate::hz(20.0))
.frame_diff(false)
.detector(from_fn(|_| Ok(DetectorOutput::Bool(true))))
.on_every_match()
.subscribe()
.unwrap();
let b = session
.watch("b")
.rate(Rate::hz(20.0))
.frame_diff(false)
.detector(from_fn(|_| Ok(DetectorOutput::Bool(true))))
.on_every_match()
.subscribe()
.unwrap();
let mut merged = a.merge(b);
let mut seen = HashSet::new();
for _ in 0..10 {
if seen.len() == 2 {
break;
}
let ev = next_event(&mut merged).await;
assert!(matches!(ev.kind, EventKind::Matched { .. }));
seen.insert(ev.watcher.to_string());
}
assert_eq!(seen, HashSet::from(["a".to_string(), "b".to_string()]));
}