use std::time::Duration;
use auv_driver::{
Capture, DisturbanceLevel, InputActionResult, InputAttempt, InputDeliveryPath, RecognizedText, Rect, Scroll, TextRecognition,
};
use image::{Rgba, RgbaImage};
use super::*;
struct FakeList {
position: i64,
content: i64,
viewport: i64,
lazy_batch: i64,
lazy_after_waits: u32,
bottom_waits: u32,
text_row: Option<i64>,
scrolls: u32,
recognitions: u32,
}
impl FakeList {
fn new(content: i64) -> Self {
Self {
position: 0,
content,
viewport: 60,
lazy_batch: 0,
lazy_after_waits: 0,
bottom_waits: 0,
text_row: None,
scrolls: 0,
recognitions: 0,
}
}
fn max_position(&self) -> i64 {
(self.content - self.viewport).max(0)
}
}
fn action() -> InputActionResult {
InputActionResult {
selected_path: InputDeliveryPath::WindowTargetedWheel,
attempts: vec![InputAttempt::success(
InputDeliveryPath::WindowTargetedWheel,
)],
verified: false,
mouse_disturbance: DisturbanceLevel::None,
focus_disturbance: DisturbanceLevel::None,
clipboard_disturbance: DisturbanceLevel::None,
}
}
impl ScrollUntilSurface for FakeList {
fn scroll(&mut self, step: &ScrollUntilStep) -> DriverResult<(InputActionResult, Scroll)> {
self.scrolls += 1;
let delta = step.delta().delta_y as i64;
self.position = (self.position + delta).clamp(0, self.max_position());
Ok((action(), step.delta()))
}
fn capture(&mut self) -> DriverResult<Capture> {
let position = self.position;
let image = RgbaImage::from_fn(40, self.viewport as u32, |_, y| {
let value = (((position + i64::from(y)) * 37).rem_euclid(251)) as u8;
Rgba([value, value.wrapping_mul(3), value.wrapping_add(90), 255])
});
Ok(Capture {
origin: None,
image,
bounds: Rect::new(100.0, 200.0, 40.0, self.viewport as f64),
scale_factor: 1.0,
backend: "fake".to_string(),
fallback_reason: None,
})
}
fn recognize_text(&mut self, _: &Capture) -> DriverResult<TextRecognition> {
self.recognitions += 1;
let visible = self.text_row.filter(|row| (self.position..self.position + self.viewport).contains(row));
let mut regions = vec![RecognizedText {
text: format!("row at {}", self.position),
bounds: Rect::new(0.0, 0.0, 40.0, 1.0),
confidence: None,
}];
regions.extend(visible.map(|row| RecognizedText {
text: "TARGET ROW".to_string(),
bounds: Rect::new(0.0, (row - self.position) as f64, 40.0, 1.0),
confidence: None,
}));
Ok(TextRecognition {
origin: None,
text: String::new(),
regions,
})
}
fn wait(&mut self, _: Duration) -> DriverResult<()> {
if self.position == self.max_position() && self.lazy_batch > 0 {
self.bottom_waits += 1;
if self.bottom_waits >= self.lazy_after_waits {
self.content += self.lazy_batch;
self.lazy_batch = 0;
}
}
Ok(())
}
}
fn request(condition: ScrollUntilCondition) -> ScrollUntilRequest {
ScrollUntilRequest {
step: ScrollUntilStep::Instant {
delta: Scroll::new(0.0, 50.0),
},
condition,
max_steps: 50,
settle: Duration::ZERO,
no_motion_confirmations: 2,
motion_region: None,
observe: ScrollUntilObserve::default(),
}
}
fn run(list: &mut FakeList, request: &ScrollUntilRequest) -> DriverResult<ScrollUntilResult> {
scroll_until(list, request, &mut |_| Ok(ScrollUntilDecision::Continue))
}
#[test]
fn end_stops_after_consecutive_no_motion_at_the_bottom() {
let mut list = FakeList::new(260);
let mut streaks = Vec::new();
let result = scroll_until(&mut list, &request(ScrollUntilCondition::End), &mut |observation| {
streaks.push(observation.no_motion_streak);
Ok(ScrollUntilDecision::Continue)
})
.unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::EndByNoVisualProgress);
assert_eq!(list.position, 200);
assert_eq!(result.steps, 6);
assert_eq!(streaks, [0, 0, 0, 0, 0, 1, 2]);
assert_eq!(result.action.unwrap().selected_path, InputDeliveryPath::WindowTargetedWheel);
}
#[test]
fn end_keeps_scrolling_when_lazy_content_arrives_during_settle() {
let mut list = FakeList::new(260);
list.lazy_batch = 300;
list.lazy_after_waits = 1;
let result = run(&mut list, &request(ScrollUntilCondition::End)).unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::EndByNoVisualProgress);
assert_eq!(list.content, 560);
assert_eq!(list.position, 500);
}
#[test]
fn text_condition_stops_as_soon_as_the_query_is_visible() {
let mut list = FakeList::new(2_000);
list.text_row = Some(420);
let result = scroll_until(
&mut list,
&request(ScrollUntilCondition::TextVisible {
query: "Target".to_string(),
}),
&mut |_| Ok(ScrollUntilDecision::Continue),
)
.unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::TextVisible);
assert!(list.position <= 420 && 420 < list.position + 60, "{}", list.position);
let matched = result.text_match.unwrap();
assert_eq!(matched.text, "TARGET ROW");
assert_eq!(matched.bounds, Rect::new(100.0, 200.0 + (420 - list.position) as f64, 40.0, 1.0));
}
#[test]
fn text_already_visible_needs_no_step() {
let mut list = FakeList::new(2_000);
list.text_row = Some(10);
let result = scroll_until(
&mut list,
&request(ScrollUntilCondition::TextVisible {
query: "Target".to_string(),
}),
&mut |_| Ok(ScrollUntilDecision::Continue),
)
.unwrap();
assert_eq!((result.reason, result.steps, list.scrolls), (ScrollUntilStopReason::TextVisible, 0, 0));
assert!(result.action.is_none());
}
#[test]
fn text_condition_stops_at_the_end_when_the_query_never_appears() {
let mut list = FakeList::new(260);
let result = scroll_until(
&mut list,
&request(ScrollUntilCondition::TextVisible {
query: "Missing".to_string(),
}),
&mut |_| Ok(ScrollUntilDecision::Continue),
)
.unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::EndByNoVisualProgress);
assert!(result.text_match.is_none());
}
#[test]
fn budget_exhaustion_is_reported_separately_from_the_end() {
let mut list = FakeList::new(10_000);
let mut short = request(ScrollUntilCondition::End);
short.max_steps = 3;
let result = run(&mut list, &short).unwrap();
assert_eq!((result.reason, result.steps), (ScrollUntilStopReason::BudgetExhausted, 3));
assert_eq!(result.delivered, Scroll::new(0.0, 150.0));
}
#[test]
fn invalid_requests_are_rejected_before_any_input() {
let base = request(ScrollUntilCondition::End);
let mut cases = Vec::new();
let mut diagonal = base.clone();
diagonal.step = ScrollUntilStep::Instant {
delta: Scroll::new(10.0, 10.0),
};
cases.push(diagonal);
let mut zero = base.clone();
zero.step = ScrollUntilStep::Instant {
delta: Scroll::new(0.0, 0.0),
};
cases.push(zero);
let mut budget = base.clone();
budget.max_steps = 0;
cases.push(budget);
let mut confirmations = base.clone();
confirmations.no_motion_confirmations = 0;
cases.push(confirmations);
let mut region = base.clone();
region.motion_region = Some(auv_driver::RatioRect::new(0.5, 0.0, 0.6, 1.0));
cases.push(region);
cases.push(request(ScrollUntilCondition::TextVisible {
query: " ".to_string(),
}));
for case in cases {
let mut list = FakeList::new(260);
assert!(matches!(run(&mut list, &case), Err(DriverError::InvalidInput { .. })), "{case:?}");
assert_eq!(list.scrolls, 0);
}
}
#[test]
fn observer_sees_every_observation_with_text_by_default() {
let mut list = FakeList::new(260);
let mut seen = Vec::new();
scroll_until(&mut list, &request(ScrollUntilCondition::End), &mut |observation| {
assert!(observation.text.is_some(), "{observation:?}");
seen.push((observation.steps, observation.motion.is_some(), observation.stop));
Ok(ScrollUntilDecision::Continue)
})
.unwrap();
assert_eq!(seen.first(), Some(&(0, false, None)), "initial observation has no motion yet");
assert_eq!(seen.len(), 7);
assert_eq!(seen.last(), Some(&(6, true, Some(ScrollUntilStopReason::EndByNoVisualProgress))));
}
#[test]
fn observer_stop_ends_the_loop_as_predicate_satisfied() {
let mut list = FakeList::new(2_000);
let result = scroll_until(&mut list, &request(ScrollUntilCondition::End), &mut |observation| {
let text = observation.text.expect("text is observed by default");
Ok(if text.regions[0].text == "row at 150" {
ScrollUntilDecision::Stop
} else {
ScrollUntilDecision::Continue
})
})
.unwrap();
assert_eq!((result.reason, result.steps, list.position), (ScrollUntilStopReason::PredicateSatisfied, 3, 150));
}
#[test]
fn observer_can_stop_before_the_first_step() {
let mut list = FakeList::new(2_000);
let result = scroll_until(&mut list, &request(ScrollUntilCondition::End), &mut |_| Ok(ScrollUntilDecision::Stop)).unwrap();
assert_eq!((result.reason, result.steps, list.scrolls), (ScrollUntilStopReason::PredicateSatisfied, 0, 0));
assert!(result.action.is_none());
}
#[test]
fn built_in_stop_wins_over_the_observer_decision() {
let mut list = FakeList::new(2_000);
list.text_row = Some(10);
let result = scroll_until(
&mut list,
&request(ScrollUntilCondition::TextVisible {
query: "target".to_string(),
}),
&mut |observation| {
assert_eq!(observation.stop, Some(ScrollUntilStopReason::TextVisible));
Ok(ScrollUntilDecision::Stop)
},
)
.unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::TextVisible);
}
#[test]
fn observer_errors_abort_the_loop() {
let mut list = FakeList::new(2_000);
let error = scroll_until(&mut list, &request(ScrollUntilCondition::End), &mut |observation| {
if observation.steps == 2 {
Err(DriverError::InvalidInput {
message: "client went away".to_string(),
})
} else {
Ok(ScrollUntilDecision::Continue)
}
})
.unwrap_err();
assert!(error.to_string().contains("client went away"), "{error}");
assert_eq!(list.scrolls, 2);
}
#[test]
fn opted_out_observations_skip_recognition() {
let mut list = FakeList::new(260);
let mut end = request(ScrollUntilCondition::End);
end.observe = ScrollUntilObserve { text: false };
scroll_until(&mut list, &end, &mut |observation| {
assert!(observation.text.is_none());
Ok(ScrollUntilDecision::Continue)
})
.unwrap();
assert_eq!(list.recognitions, 0);
}
#[test]
fn text_condition_still_recognizes_when_text_is_opted_out() {
let mut list = FakeList::new(2_000);
list.text_row = Some(420);
let mut find = request(ScrollUntilCondition::TextVisible {
query: "target".to_string(),
});
find.observe.text = false;
let result = scroll_until(&mut list, &find, &mut |observation| {
assert!(observation.text.is_none());
Ok(ScrollUntilDecision::Continue)
})
.unwrap();
assert_eq!(result.reason, ScrollUntilStopReason::TextVisible);
assert!(list.recognitions > 0);
}