Skip to main content

cranpose_testing/
robot_helpers.rs

1//! Common helper functions for robot tests
2//!
3//! These helpers work with `cranpose::SemanticElement` to find and interact
4//! with UI elements during robot testing.
5
6use crate::robot_assertions::{Bounds, SemanticElementLike};
7use cranpose::SemanticElement;
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13// Implement SemanticElementLike for cranpose::SemanticElement
14// This allows using the generic assertion helpers from robot_assertions
15impl SemanticElementLike for SemanticElement {
16    fn text(&self) -> Option<&str> {
17        self.text.as_deref()
18    }
19
20    fn role(&self) -> &str {
21        &self.role
22    }
23
24    fn clickable(&self) -> bool {
25        self.clickable
26    }
27
28    fn bounds(&self) -> Bounds {
29        Bounds {
30            x: self.bounds.x,
31            y: self.bounds.y,
32            width: self.bounds.width,
33            height: self.bounds.height,
34        }
35    }
36
37    fn children(&self) -> &[Self] {
38        &self.children
39    }
40}
41
42/// Find an element by text content (partial match), returning bounds (x, y, width, height).
43///
44/// Searches the entire subtree depth-first.
45///
46/// # Returns
47/// Some((x, y, width, height)) if found, None otherwise.
48pub fn find_text(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
49    if let Some(ref t) = elem.text {
50        if t.contains(text) {
51            return Some((
52                elem.bounds.x,
53                elem.bounds.y,
54                elem.bounds.width,
55                elem.bounds.height,
56            ));
57        }
58    }
59    for child in &elem.children {
60        if let Some(pos) = find_text(child, text) {
61            return Some(pos);
62        }
63    }
64    None
65}
66
67/// Find an element by exact text content, returning bounds (x, y, width, height).
68///
69/// Useful when partial matches might be ambiguous.
70pub fn find_text_exact(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
71    if let Some(ref t) = elem.text {
72        if t == text {
73            return Some((
74                elem.bounds.x,
75                elem.bounds.y,
76                elem.bounds.width,
77                elem.bounds.height,
78            ));
79        }
80    }
81    for child in &elem.children {
82        if let Some(pos) = find_text_exact(child, text) {
83            return Some(pos);
84        }
85    }
86    None
87}
88
89/// Find an element by text content, returning center coordinates (x, y).
90///
91/// This is a convenience helper for clicking elements.
92pub fn find_text_center(elem: &SemanticElement, text: &str) -> Option<(f32, f32)> {
93    find_text(elem, text).map(|(x, y, w, h)| (x + w / 2.0, y + h / 2.0))
94}
95
96/// Check if an element or any of its children contains the specified text.
97pub fn has_text(elem: &SemanticElement, text: &str) -> bool {
98    has_text_by(elem, text, text_contains)
99}
100
101/// Find a clickable element (button) containing the specified text.
102///
103/// Matches elements where `clickable == true` AND the element (or its children) contains the text.
104/// Returns bounds (x, y, width, height).
105pub fn find_button(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
106    find_button_by(elem, text, text_contains)
107}
108
109/// Find a clickable element (button) containing the specified text.
110/// Returns center coordinates (x, y).
111pub fn find_button_center(elem: &SemanticElement, text: &str) -> Option<(f32, f32)> {
112    find_button(elem, text).map(|(x, y, w, h)| (x + w / 2.0, y + h / 2.0))
113}
114
115/// Search the semantic tree from Robot, applying a finder function.
116///
117/// This handles the boilerplate of fetching semantics and iterating through roots.
118/// It creates a unified search context for finder functions.
119///
120/// # Returns
121/// The first result (x, y, w, h) returned by the finder function.
122pub fn find_in_semantics<F>(robot: &cranpose::Robot, finder: F) -> Option<(f32, f32, f32, f32)>
123where
124    F: Fn(&SemanticElement) -> Option<(f32, f32, f32, f32)>,
125{
126    match robot.get_semantics() {
127        Ok(semantics) => {
128            for root in semantics.iter() {
129                if let Some(result) = finder(root) {
130                    return Some(result);
131                }
132            }
133            None
134        }
135        Err(e) => {
136            eprintln!("  ✗ Failed to get semantics: {}", e);
137            None
138        }
139    }
140}
141
142/// Find element by text in semantics tree (Robot wrapper).
143///
144/// Fetches the current semantics tree from the robot and searches for text.
145/// Returns bounds (x, y, width, height).
146pub fn find_text_in_semantics(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32, f32, f32)> {
147    match robot.find_text_bounds(text) {
148        Ok(bounds) => bounds,
149        Err(e) => {
150            eprintln!("  ✗ Failed to query text semantics: {}", e);
151            None
152        }
153    }
154}
155
156/// Find an element whose text starts with the given prefix.
157/// Returns bounds (x, y, width, height) and the full text.
158pub fn find_text_by_prefix(
159    elem: &SemanticElement,
160    prefix: &str,
161) -> Option<(f32, f32, f32, f32, String)> {
162    if let Some(ref t) = elem.text {
163        if t.starts_with(prefix) {
164            return Some((
165                elem.bounds.x,
166                elem.bounds.y,
167                elem.bounds.width,
168                elem.bounds.height,
169                t.clone(),
170            ));
171        }
172    }
173    for child in &elem.children {
174        if let Some(result) = find_text_by_prefix(child, prefix) {
175            return Some(result);
176        }
177    }
178    None
179}
180
181/// Find element by text prefix in semantics tree.
182/// Returns bounds (x, y, width, height) and the full text content.
183/// Useful for parsing dynamic text like "Stats: C=5 E=3 D=2".
184pub fn find_text_by_prefix_in_semantics(
185    robot: &cranpose::Robot,
186    prefix: &str,
187) -> Option<(f32, f32, f32, f32, String)> {
188    match robot.find_text_by_prefix(prefix) {
189        Ok(bounds) => bounds,
190        Err(e) => {
191            eprintln!("  ✗ Failed to query text prefix semantics: {}", e);
192            None
193        }
194    }
195}
196
197/// Find button by text in semantics tree.
198/// Convenience wrapper around find_in_semantics + find_button.
199pub fn find_button_in_semantics(
200    robot: &cranpose::Robot,
201    text: &str,
202) -> Option<(f32, f32, f32, f32)> {
203    find_button_in_semantics_by(robot, text, TextMatchMode::Contains)
204}
205
206/// Find button by exact text in semantics tree.
207pub fn find_button_exact_in_semantics(
208    robot: &cranpose::Robot,
209    text: &str,
210) -> Option<(f32, f32, f32, f32)> {
211    find_button_in_semantics_by(robot, text, TextMatchMode::Exact)
212}
213
214fn find_button_in_semantics_by(
215    robot: &cranpose::Robot,
216    text: &str,
217    match_mode: TextMatchMode,
218) -> Option<(f32, f32, f32, f32)> {
219    let text_owned = text.to_string();
220    let mut bounds = find_button_bounds_for_mode(robot, &text_owned, match_mode);
221
222    let Some(root) = root_bounds(robot) else {
223        return bounds;
224    };
225
226    if let Some(current) = bounds {
227        if is_fully_visible(current, root) {
228            return bounds;
229        }
230    } else {
231        return None;
232    }
233
234    for _ in 0..8 {
235        let Some(current) = bounds else {
236            break;
237        };
238
239        let Some((axis, dir)) = overflow_axis_direction(current, root) else {
240            break;
241        };
242
243        let semantics = match robot.get_semantics() {
244            Ok(semantics) => semantics,
245            Err(e) => {
246                eprintln!("  ✗ Failed to get semantics: {}", e);
247                break;
248            }
249        };
250
251        let Some((sx, sy, sw, sh)) = find_scroll_anchor(&semantics, current, root, axis) else {
252            break;
253        };
254
255        let start_x = sx + sw / 2.0;
256        let start_y = sy + sh / 2.0;
257        let (end_x, end_y) = match axis {
258            TabAxis::Horizontal => (start_x + dir * SCROLL_STEP, start_y),
259            TabAxis::Vertical => (start_x, start_y + dir * SCROLL_STEP),
260        };
261        let _ = robot.drag(start_x, start_y, end_x, end_y);
262        std::thread::sleep(Duration::from_millis(SCROLL_SETTLE_MS));
263        let _ = robot.wait_for_idle();
264
265        bounds = find_button_bounds_for_mode(robot, &text_owned, match_mode);
266        if let Some(current) = bounds {
267            if is_fully_visible(current, root) {
268                break;
269            }
270        }
271    }
272
273    bounds
274}
275
276/// Recursively search for text in semantic elements.
277/// Returns the element containing the text.
278pub fn find_by_text_recursive(elements: &[SemanticElement], text: &str) -> Option<SemanticElement> {
279    for elem in elements {
280        if let Some(ref elem_text) = elem.text {
281            if elem_text.contains(text) {
282                return Some(elem.clone());
283            }
284        }
285        if let Some(found) = find_by_text_recursive(&elem.children, text) {
286            return Some(found);
287        }
288    }
289    None
290}
291
292/// Find all clickable elements in a specific Y range.
293/// Returns a list of (label, x, y) tuples sorted by x position.
294pub fn find_clickables_in_range(
295    elements: &[SemanticElement],
296    min_y: f32,
297    max_y: f32,
298) -> Vec<(String, f32, f32)> {
299    fn search(elem: &SemanticElement, tabs: &mut Vec<(String, f32, f32)>, min_y: f32, max_y: f32) {
300        if elem.role == "Layout" && elem.clickable && elem.bounds.y > min_y && elem.bounds.y < max_y
301        {
302            let label = elem
303                .children
304                .iter()
305                .find(|child| child.role == "Text")
306                .and_then(|text_elem| text_elem.text.clone())
307                .unwrap_or_else(|| "Unknown".to_string());
308            tabs.push((label, elem.bounds.x, elem.bounds.y));
309        }
310        for child in &elem.children {
311            search(child, tabs, min_y, max_y);
312        }
313    }
314
315    let mut tabs = Vec::new();
316    for elem in elements {
317        search(elem, &mut tabs, min_y, max_y);
318    }
319    tabs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
320    tabs
321}
322
323pub fn find_element_by_text_exact<'a>(
324    elements: &'a [SemanticElement],
325    text: &str,
326) -> Option<&'a SemanticElement> {
327    for elem in elements {
328        if elem.text.as_deref() == Some(text) {
329            return Some(elem);
330        }
331        if let Some(found) = find_element_by_text_exact(&elem.children, text) {
332            return Some(found);
333        }
334    }
335    None
336}
337
338pub fn find_bounds_by_text(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32, f32, f32)> {
339    let semantics = robot.get_semantics().ok()?;
340    let elem = find_element_by_text_exact(&semantics, text)?;
341    Some((
342        elem.bounds.x,
343        elem.bounds.y,
344        elem.bounds.width,
345        elem.bounds.height,
346    ))
347}
348
349pub fn visible_bounds_in_viewport(
350    robot: &cranpose::Robot,
351    bounds: (f32, f32, f32, f32),
352    padding: f32,
353) -> Option<(f32, f32, f32, f32)> {
354    let semantics = robot.get_semantics().ok()?;
355    let mut viewport = None;
356    for elem in semantics.iter() {
357        let elem_bounds = (
358            elem.bounds.x,
359            elem.bounds.y,
360            elem.bounds.width,
361            elem.bounds.height,
362        );
363        viewport = Some(match viewport {
364            Some(existing) => union_bounds(existing, Some(elem_bounds)),
365            None => elem_bounds,
366        });
367    }
368    let (viewport_x, viewport_y, viewport_width, viewport_height) = viewport?;
369    let min_x = viewport_x + padding;
370    let min_y = viewport_y + padding;
371    let max_x = viewport_x + viewport_width - padding;
372    let max_y = viewport_y + viewport_height - padding;
373
374    let left = bounds.0.max(min_x);
375    let top = bounds.1.max(min_y);
376    let right = (bounds.0 + bounds.2).min(max_x);
377    let bottom = (bounds.1 + bounds.3).min(max_y);
378
379    if right <= left || bottom <= top {
380        None
381    } else {
382        Some((left, top, right - left, bottom - top))
383    }
384}
385
386pub fn find_center_by_text(robot: &cranpose::Robot, text: &str) -> Option<(f32, f32)> {
387    let (x, y, w, h) = find_bounds_by_text(robot, text)?;
388    Some((x + w / 2.0, y + h / 2.0))
389}
390
391pub fn find_in_subtree_by_text<'a>(
392    elem: &'a SemanticElement,
393    text: &str,
394) -> Option<&'a SemanticElement> {
395    if elem.text.as_deref() == Some(text) {
396        return Some(elem);
397    }
398    for child in &elem.children {
399        if let Some(found) = find_in_subtree_by_text(child, text) {
400            return Some(found);
401        }
402    }
403    None
404}
405
406pub fn print_semantics_with_bounds(elements: &[SemanticElement], indent: usize) {
407    for elem in elements {
408        let prefix = "  ".repeat(indent);
409        let text = elem.text.as_deref().unwrap_or("");
410        println!(
411            "{}role={} text=\"{}\" bounds=({:.1},{:.1},{:.1},{:.1}){}",
412            prefix,
413            elem.role,
414            text,
415            elem.bounds.x,
416            elem.bounds.y,
417            elem.bounds.width,
418            elem.bounds.height,
419            if elem.clickable { " [CLICKABLE]" } else { "" }
420        );
421        print_semantics_with_bounds(&elem.children, indent + 1);
422    }
423}
424
425pub fn union_bounds(
426    base: (f32, f32, f32, f32),
427    other: Option<(f32, f32, f32, f32)>,
428) -> (f32, f32, f32, f32) {
429    let (x, y, w, h) = base;
430    let mut min_x = x;
431    let mut min_y = y;
432    let mut max_x = x + w;
433    let mut max_y = y + h;
434    if let Some((ox, oy, ow, oh)) = other {
435        min_x = min_x.min(ox);
436        min_y = min_y.min(oy);
437        max_x = max_x.max(ox + ow);
438        max_y = max_y.max(oy + oh);
439    }
440    (min_x, min_y, max_x - min_x, max_y - min_y)
441}
442
443pub fn count_text_in_tree(elements: &[SemanticElement], text: &str) -> usize {
444    let mut count = 0;
445    for elem in elements {
446        if elem.text.as_deref() == Some(text) {
447            count += 1;
448        }
449        count += count_text_in_tree(&elem.children, text);
450    }
451    count
452}
453
454pub fn collect_by_text_exact<'a>(
455    elements: &'a [SemanticElement],
456    text: &str,
457    results: &mut Vec<&'a SemanticElement>,
458) {
459    for elem in elements {
460        if elem.text.as_deref() == Some(text) {
461            results.push(elem);
462        }
463        collect_by_text_exact(&elem.children, text, results);
464    }
465}
466
467pub fn collect_text_prefix_counts(
468    elements: &[SemanticElement],
469    prefix: &str,
470    counts: &mut HashMap<String, usize>,
471) {
472    for elem in elements {
473        if let Some(text) = elem.text.as_deref() {
474            if text.starts_with(prefix) {
475                *counts.entry(text.to_string()).or_insert(0) += 1;
476            }
477        }
478        collect_text_prefix_counts(&elem.children, prefix, counts);
479    }
480}
481
482pub fn exit_with_timeout(robot: &cranpose::Robot, timeout: Duration) {
483    let done = Arc::new(AtomicBool::new(false));
484    let done_thread = Arc::clone(&done);
485    std::thread::spawn(move || {
486        std::thread::sleep(timeout);
487        if !done_thread.load(Ordering::Relaxed) {
488            std::process::exit(0);
489        }
490    });
491
492    let _ = robot.exit();
493    done.store(true, Ordering::Relaxed);
494}
495
496#[derive(Clone, Copy, Debug, PartialEq, Eq)]
497pub enum TabAxis {
498    Horizontal,
499    Vertical,
500}
501
502type RectBounds = (f32, f32, f32, f32);
503type LabeledRect = (String, RectBounds);
504
505pub fn collect_tab_bounds(robot: &cranpose::Robot, labels: &[&str]) -> Vec<LabeledRect> {
506    let mut tabs = Vec::new();
507    for label in labels {
508        if let Some(bounds) = find_in_semantics(robot, |elem| find_button(elem, label)) {
509            tabs.push(((*label).to_string(), bounds));
510        }
511    }
512    tabs
513}
514
515pub fn bounds_span(bounds: &[LabeledRect]) -> Option<RectBounds> {
516    let mut iter = bounds.iter();
517    let (_, (x, y, w, h)) = iter.next()?;
518    let mut min_x = *x;
519    let mut min_y = *y;
520    let mut max_x = x + w;
521    let mut max_y = y + h;
522    for (_, (bx, by, bw, bh)) in iter {
523        min_x = min_x.min(*bx);
524        min_y = min_y.min(*by);
525        max_x = max_x.max(*bx + *bw);
526        max_y = max_y.max(*by + *bh);
527    }
528    Some((min_x, min_y, max_x, max_y))
529}
530
531pub fn detect_tab_axis(bounds: &[LabeledRect]) -> Option<TabAxis> {
532    let (min_x, min_y, max_x, max_y) = bounds_span(bounds)?;
533    let span_x = max_x - min_x;
534    let span_y = max_y - min_y;
535    if span_x >= span_y {
536        Some(TabAxis::Horizontal)
537    } else {
538        Some(TabAxis::Vertical)
539    }
540}
541
542pub fn root_bounds(robot: &cranpose::Robot) -> Option<RectBounds> {
543    if let Ok(screenshot) = robot.screenshot() {
544        if screenshot.logical_width.is_finite()
545            && screenshot.logical_width > 0.0
546            && screenshot.logical_height.is_finite()
547            && screenshot.logical_height > 0.0
548        {
549            return Some((
550                0.0,
551                0.0,
552                screenshot.logical_width,
553                screenshot.logical_height,
554            ));
555        }
556    }
557
558    let semantics = robot.get_semantics().ok()?;
559    let root = semantics.first()?;
560    Some((
561        root.bounds.x,
562        root.bounds.y,
563        root.bounds.width,
564        root.bounds.height,
565    ))
566}
567
568const VISIBILITY_PADDING: f32 = 4.0;
569const SCROLL_STEP: f32 = 240.0;
570const SCROLL_SETTLE_MS: u64 = 140;
571type TextMatcher = fn(&str, &str) -> bool;
572
573#[derive(Clone, Copy, Debug, PartialEq, Eq)]
574enum TextMatchMode {
575    Contains,
576    Exact,
577}
578
579fn text_contains(actual: &str, needle: &str) -> bool {
580    actual.contains(needle)
581}
582
583#[cfg(test)]
584fn text_equals(actual: &str, needle: &str) -> bool {
585    actual == needle
586}
587
588fn find_button_bounds_for_mode(
589    robot: &cranpose::Robot,
590    text: &str,
591    match_mode: TextMatchMode,
592) -> Option<(f32, f32, f32, f32)> {
593    let result = match match_mode {
594        TextMatchMode::Contains => robot.find_button_bounds(text),
595        TextMatchMode::Exact => robot.find_button_bounds_exact(text),
596    };
597    match result {
598        Ok(bounds) => bounds,
599        Err(e) => {
600            eprintln!("  ✗ Failed to query button semantics: {}", e);
601            None
602        }
603    }
604}
605
606fn has_text_by(elem: &SemanticElement, text: &str, matcher: TextMatcher) -> bool {
607    if elem
608        .text
609        .as_deref()
610        .is_some_and(|actual| matcher(actual, text))
611    {
612        return true;
613    }
614    elem.children
615        .iter()
616        .any(|child| has_text_by(child, text, matcher))
617}
618
619fn find_button_by(
620    elem: &SemanticElement,
621    text: &str,
622    matcher: TextMatcher,
623) -> Option<(f32, f32, f32, f32)> {
624    if elem.clickable && has_text_by(elem, text, matcher) {
625        return Some((
626            elem.bounds.x,
627            elem.bounds.y,
628            elem.bounds.width,
629            elem.bounds.height,
630        ));
631    }
632    elem.children
633        .iter()
634        .find_map(|child| find_button_by(child, text, matcher))
635}
636
637fn is_axis_visible(bounds: RectBounds, root: RectBounds, axis: TabAxis) -> bool {
638    let (x, y, w, h) = bounds;
639    let (rx, ry, rw, rh) = root;
640    match axis {
641        TabAxis::Horizontal => {
642            x >= rx + VISIBILITY_PADDING && x + w <= rx + rw - VISIBILITY_PADDING
643        }
644        TabAxis::Vertical => y >= ry + VISIBILITY_PADDING && y + h <= ry + rh - VISIBILITY_PADDING,
645    }
646}
647
648fn is_fully_visible(bounds: RectBounds, root: RectBounds) -> bool {
649    is_axis_visible(bounds, root, TabAxis::Horizontal)
650        && is_axis_visible(bounds, root, TabAxis::Vertical)
651}
652
653fn overflow_axis_direction(bounds: RectBounds, root: RectBounds) -> Option<(TabAxis, f32)> {
654    let (x, y, w, h) = bounds;
655    let (rx, ry, rw, rh) = root;
656
657    let overflow_left = (rx + VISIBILITY_PADDING - x).max(0.0);
658    let overflow_right = (x + w - (rx + rw - VISIBILITY_PADDING)).max(0.0);
659    let overflow_top = (ry + VISIBILITY_PADDING - y).max(0.0);
660    let overflow_bottom = (y + h - (ry + rh - VISIBILITY_PADDING)).max(0.0);
661
662    let horizontal_overflow = overflow_left.max(overflow_right);
663    let vertical_overflow = overflow_top.max(overflow_bottom);
664
665    if horizontal_overflow <= 0.0 && vertical_overflow <= 0.0 {
666        return None;
667    }
668
669    if horizontal_overflow >= vertical_overflow {
670        if overflow_left > 0.0 {
671            Some((TabAxis::Horizontal, 1.0))
672        } else {
673            Some((TabAxis::Horizontal, -1.0))
674        }
675    } else if overflow_top > 0.0 {
676        Some((TabAxis::Vertical, 1.0))
677    } else {
678        Some((TabAxis::Vertical, -1.0))
679    }
680}
681
682fn intersects_root(bounds: RectBounds, root: RectBounds) -> bool {
683    let (x, y, w, h) = bounds;
684    let (rx, ry, rw, rh) = root;
685    let left = x.max(rx);
686    let top = y.max(ry);
687    let right = (x + w).min(rx + rw);
688    let bottom = (y + h).min(ry + rh);
689    right > left && bottom > top
690}
691
692fn overlap_len(a_start: f32, a_len: f32, b_start: f32, b_len: f32) -> f32 {
693    let a_end = a_start + a_len;
694    let b_end = b_start + b_len;
695    (a_end.min(b_end) - a_start.max(b_start)).max(0.0)
696}
697
698fn cross_axis_overlap(bounds: RectBounds, target: RectBounds, axis: TabAxis) -> f32 {
699    match axis {
700        TabAxis::Horizontal => overlap_len(bounds.1, bounds.3, target.1, target.3),
701        TabAxis::Vertical => overlap_len(bounds.0, bounds.2, target.0, target.2),
702    }
703}
704
705fn primary_axis_distance(bounds: RectBounds, target: RectBounds, axis: TabAxis) -> f32 {
706    let center = match axis {
707        TabAxis::Horizontal => bounds.0 + bounds.2 / 2.0,
708        TabAxis::Vertical => bounds.1 + bounds.3 / 2.0,
709    };
710    let target_center = match axis {
711        TabAxis::Horizontal => target.0 + target.2 / 2.0,
712        TabAxis::Vertical => target.1 + target.3 / 2.0,
713    };
714    (center - target_center).abs()
715}
716
717fn find_scroll_anchor(
718    elements: &[SemanticElement],
719    target: RectBounds,
720    root: RectBounds,
721    axis: TabAxis,
722) -> Option<RectBounds> {
723    let mut best: Option<(RectBounds, f32, f32)> = None;
724
725    for elem in elements {
726        if elem.clickable {
727            let bounds = (
728                elem.bounds.x,
729                elem.bounds.y,
730                elem.bounds.width,
731                elem.bounds.height,
732            );
733            if is_fully_visible(bounds, root) && intersects_root(bounds, root) {
734                let overlap = cross_axis_overlap(bounds, target, axis);
735                if overlap > 0.0 {
736                    let distance = primary_axis_distance(bounds, target, axis);
737                    match best {
738                        None => best = Some((bounds, overlap, distance)),
739                        Some((_, best_overlap, best_distance)) => {
740                            let is_better_overlap = overlap > best_overlap + f32::EPSILON;
741                            let is_better_distance = (overlap - best_overlap).abs() <= f32::EPSILON
742                                && distance < best_distance;
743                            if is_better_overlap || is_better_distance {
744                                best = Some((bounds, overlap, distance));
745                            }
746                        }
747                    }
748                }
749            }
750        }
751
752        if let Some(found) = find_scroll_anchor(&elem.children, target, root, axis) {
753            let overlap = cross_axis_overlap(found, target, axis);
754            let distance = primary_axis_distance(found, target, axis);
755            match best {
756                None => best = Some((found, overlap, distance)),
757                Some((_, best_overlap, best_distance)) => {
758                    let is_better_overlap = overlap > best_overlap + f32::EPSILON;
759                    let is_better_distance =
760                        (overlap - best_overlap).abs() <= f32::EPSILON && distance < best_distance;
761                    if is_better_overlap || is_better_distance {
762                        best = Some((found, overlap, distance));
763                    }
764                }
765            }
766        }
767    }
768
769    best.map(|(bounds, _, _)| bounds)
770}
771
772/// Capture a full screenshot from the running robot app.
773pub fn capture_screenshot(robot: &cranpose::Robot) -> Option<cranpose::RobotScreenshot> {
774    robot.screenshot().ok()
775}
776
777fn screenshot_logical_width(screenshot: &cranpose::RobotScreenshot) -> f32 {
778    if screenshot.logical_width.is_finite() && screenshot.logical_width > 0.0 {
779        screenshot.logical_width
780    } else {
781        screenshot.width.max(1) as f32
782    }
783}
784
785fn screenshot_logical_height(screenshot: &cranpose::RobotScreenshot) -> f32 {
786    if screenshot.logical_height.is_finite() && screenshot.logical_height > 0.0 {
787        screenshot.logical_height
788    } else {
789        screenshot.height.max(1) as f32
790    }
791}
792
793fn screenshot_scale_x(screenshot: &cranpose::RobotScreenshot) -> f32 {
794    screenshot.width.max(1) as f32 / screenshot_logical_width(screenshot)
795}
796
797fn screenshot_scale_y(screenshot: &cranpose::RobotScreenshot) -> f32 {
798    screenshot.height.max(1) as f32 / screenshot_logical_height(screenshot)
799}
800
801fn logical_to_screenshot_x(screenshot: &cranpose::RobotScreenshot, x: f32) -> f32 {
802    x * screenshot_scale_x(screenshot)
803}
804
805fn logical_to_screenshot_y(screenshot: &cranpose::RobotScreenshot, y: f32) -> f32 {
806    y * screenshot_scale_y(screenshot)
807}
808
809pub fn screenshot_logical_size(screenshot: &cranpose::RobotScreenshot) -> (f32, f32) {
810    (
811        screenshot_logical_width(screenshot),
812        screenshot_logical_height(screenshot),
813    )
814}
815
816/// Returns pixel RGBA at `(x, y)` from a screenshot.
817pub fn screenshot_pixel(screenshot: &cranpose::RobotScreenshot, x: u32, y: u32) -> Option<[u8; 4]> {
818    if x >= screenshot.width || y >= screenshot.height {
819        return None;
820    }
821    let index = ((y * screenshot.width + x) * 4) as usize;
822    Some([
823        screenshot.pixels[index],
824        screenshot.pixels[index + 1],
825        screenshot.pixels[index + 2],
826        screenshot.pixels[index + 3],
827    ])
828}
829
830pub fn sample_screenshot_pixel_logical(
831    screenshot: &cranpose::RobotScreenshot,
832    x: f32,
833    y: f32,
834) -> Option<[u8; 4]> {
835    let logical_width = screenshot_logical_width(screenshot);
836    let logical_height = screenshot_logical_height(screenshot);
837    if x < 0.0 || y < 0.0 || x > logical_width || y > logical_height {
838        return None;
839    }
840
841    Some(sample_screenshot_pixel_bilinear(
842        screenshot,
843        logical_to_screenshot_x(screenshot, x),
844        logical_to_screenshot_y(screenshot, y),
845    ))
846}
847
848pub fn logical_region_to_pixel_bounds(
849    screenshot: &cranpose::RobotScreenshot,
850    region: (f32, f32, f32, f32),
851) -> Option<(u32, u32, u32, u32)> {
852    if region.2 <= 0.0 || region.3 <= 0.0 || screenshot.width == 0 || screenshot.height == 0 {
853        return None;
854    }
855
856    let left = logical_to_screenshot_x(screenshot, region.0.max(0.0))
857        .floor()
858        .max(0.0) as u32;
859    let top = logical_to_screenshot_y(screenshot, region.1.max(0.0))
860        .floor()
861        .max(0.0) as u32;
862    let right = logical_to_screenshot_x(
863        screenshot,
864        (region.0 + region.2).min(screenshot_logical_width(screenshot)),
865    )
866    .ceil()
867    .min(screenshot.width as f32) as u32;
868    let bottom = logical_to_screenshot_y(
869        screenshot,
870        (region.1 + region.3).min(screenshot_logical_height(screenshot)),
871    )
872    .ceil()
873    .min(screenshot.height as f32) as u32;
874
875    if right <= left || bottom <= top {
876        return None;
877    }
878
879    Some((left, top, right, bottom))
880}
881
882/// Crops a rectangular region from a screenshot.
883pub fn crop_screenshot(
884    screenshot: &cranpose::RobotScreenshot,
885    x: u32,
886    y: u32,
887    width: u32,
888    height: u32,
889) -> Option<cranpose::RobotScreenshot> {
890    if width == 0 || height == 0 {
891        return None;
892    }
893    let end_x = x.checked_add(width)?;
894    let end_y = y.checked_add(height)?;
895    if end_x > screenshot.width || end_y > screenshot.height {
896        return None;
897    }
898
899    let mut pixels = vec![0u8; (width * height * 4) as usize];
900    for row in 0..height {
901        let src_start = (((y + row) * screenshot.width + x) * 4) as usize;
902        let src_end = src_start + (width * 4) as usize;
903        let dst_start = (row * width * 4) as usize;
904        let dst_end = dst_start + (width * 4) as usize;
905        pixels[dst_start..dst_end].copy_from_slice(&screenshot.pixels[src_start..src_end]);
906    }
907
908    Some(cranpose::RobotScreenshot {
909        width,
910        height,
911        logical_width: width as f32 / screenshot_scale_x(screenshot),
912        logical_height: height as f32 / screenshot_scale_y(screenshot),
913        pixels,
914    })
915}
916
917pub fn crop_screenshot_logical(
918    screenshot: &cranpose::RobotScreenshot,
919    x: f32,
920    y: f32,
921    width: f32,
922    height: f32,
923) -> Option<cranpose::RobotScreenshot> {
924    let (left, top, right, bottom) =
925        logical_region_to_pixel_bounds(screenshot, (x, y, width, height))?;
926    crop_screenshot(screenshot, left, top, right - left, bottom - top)
927}
928
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ScreenshotPixelDifference {
931    pub x: u32,
932    pub y: u32,
933    pub before: [u8; 4],
934    pub after: [u8; 4],
935    pub difference: u32,
936}
937
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct ScreenshotDifferenceStats {
940    pub differing_pixels: usize,
941    pub max_difference: u32,
942    pub first_difference: Option<ScreenshotPixelDifference>,
943}
944
945/// Normalize a floating-point screenshot region into a stable pixel grid with bilinear sampling.
946///
947/// This is useful for comparing the local picture of a translated subtree after compensating for
948/// parent motion.
949pub fn normalize_screenshot_region(
950    screenshot: &cranpose::RobotScreenshot,
951    region: (f32, f32, f32, f32),
952    output_width: u32,
953    output_height: u32,
954) -> Option<cranpose::RobotScreenshot> {
955    if output_width == 0
956        || output_height == 0
957        || region.2 <= 0.0
958        || region.3 <= 0.0
959        || screenshot.width == 0
960        || screenshot.height == 0
961    {
962        return None;
963    }
964
965    let mut pixels = Vec::with_capacity((output_width * output_height * 4) as usize);
966    for y in 0..output_height {
967        for x in 0..output_width {
968            let sample_x = region.0 + ((x as f32 + 0.5) * region.2 / output_width as f32);
969            let sample_y = region.1 + ((y as f32 + 0.5) * region.3 / output_height as f32);
970            pixels.extend_from_slice(&sample_screenshot_pixel_logical(
971                screenshot, sample_x, sample_y,
972            )?);
973        }
974    }
975
976    Some(cranpose::RobotScreenshot {
977        width: output_width,
978        height: output_height,
979        logical_width: output_width as f32,
980        logical_height: output_height as f32,
981        pixels,
982    })
983}
984
985/// Count differing pixels and report the strongest difference between two screenshots of equal
986/// dimensions. The difference metric is the maximum per-channel absolute difference.
987pub fn screenshot_difference_stats(
988    before: &cranpose::RobotScreenshot,
989    after: &cranpose::RobotScreenshot,
990    difference_tolerance: u32,
991) -> Option<ScreenshotDifferenceStats> {
992    if before.width != after.width || before.height != after.height {
993        return None;
994    }
995
996    let mut differing_pixels = 0usize;
997    let mut max_difference = 0u32;
998    let mut first_difference = None;
999
1000    for y in 0..before.height {
1001        for x in 0..before.width {
1002            let before_pixel =
1003                screenshot_pixel(before, x, y).expect("screenshot bounds checked by loop");
1004            let after_pixel =
1005                screenshot_pixel(after, x, y).expect("screenshot bounds checked by loop");
1006            let difference = pixel_difference(before_pixel, after_pixel);
1007            if difference > difference_tolerance {
1008                differing_pixels += 1;
1009                max_difference = max_difference.max(difference);
1010                if first_difference.is_none() {
1011                    first_difference = Some(ScreenshotPixelDifference {
1012                        x,
1013                        y,
1014                        before: before_pixel,
1015                        after: after_pixel,
1016                        difference,
1017                    });
1018                }
1019            }
1020        }
1021    }
1022
1023    Some(ScreenshotDifferenceStats {
1024        differing_pixels,
1025        max_difference,
1026        first_difference,
1027    })
1028}
1029
1030/// Count pixels that differ between two screenshots by more than `channel_threshold`
1031/// on any RGBA channel.
1032pub fn changed_pixel_count(
1033    before: &cranpose::RobotScreenshot,
1034    after: &cranpose::RobotScreenshot,
1035    channel_threshold: u8,
1036) -> usize {
1037    if before.width != after.width || before.height != after.height {
1038        return usize::MAX;
1039    }
1040
1041    before
1042        .pixels
1043        .chunks_exact(4)
1044        .zip(after.pixels.chunks_exact(4))
1045        .filter(|(a, b)| {
1046            a[0].abs_diff(b[0]) > channel_threshold
1047                || a[1].abs_diff(b[1]) > channel_threshold
1048                || a[2].abs_diff(b[2]) > channel_threshold
1049                || a[3].abs_diff(b[3]) > channel_threshold
1050        })
1051        .count()
1052}
1053
1054/// Count pixels that differ within a sub-region (x, y, w, h) of two screenshots.
1055pub fn changed_pixel_count_in_region(
1056    before: &cranpose::RobotScreenshot,
1057    after: &cranpose::RobotScreenshot,
1058    region: (f32, f32, f32, f32),
1059    channel_threshold: u8,
1060) -> usize {
1061    if before.width != after.width
1062        || before.height != after.height
1063        || (screenshot_scale_x(before) - screenshot_scale_x(after)).abs() > f32::EPSILON
1064        || (screenshot_scale_y(before) - screenshot_scale_y(after)).abs() > f32::EPSILON
1065    {
1066        return usize::MAX;
1067    }
1068
1069    let Some((left, top, right, bottom)) = logical_region_to_pixel_bounds(before, region) else {
1070        return 0;
1071    };
1072
1073    let width = before.width as usize;
1074    let mut changed = 0usize;
1075    for y in top..bottom {
1076        for x in left..right {
1077            let idx = ((y as usize) * width + x as usize) * 4;
1078            if before.pixels[idx].abs_diff(after.pixels[idx]) > channel_threshold
1079                || before.pixels[idx + 1].abs_diff(after.pixels[idx + 1]) > channel_threshold
1080                || before.pixels[idx + 2].abs_diff(after.pixels[idx + 2]) > channel_threshold
1081                || before.pixels[idx + 3].abs_diff(after.pixels[idx + 3]) > channel_threshold
1082            {
1083                changed += 1;
1084            }
1085        }
1086    }
1087
1088    changed
1089}
1090
1091fn sample_screenshot_pixel_bilinear(
1092    screenshot: &cranpose::RobotScreenshot,
1093    x: f32,
1094    y: f32,
1095) -> [u8; 4] {
1096    let max_x = screenshot.width.saturating_sub(1) as f32;
1097    let max_y = screenshot.height.saturating_sub(1) as f32;
1098    let source_x = (x - 0.5).clamp(0.0, max_x);
1099    let source_y = (y - 0.5).clamp(0.0, max_y);
1100    let x0 = source_x.floor() as u32;
1101    let y0 = source_y.floor() as u32;
1102    let x1 = (x0 + 1).min(screenshot.width.saturating_sub(1));
1103    let y1 = (y0 + 1).min(screenshot.height.saturating_sub(1));
1104    let tx = source_x - x0 as f32;
1105    let ty = source_y - y0 as f32;
1106    let top_left = screenshot_pixel(screenshot, x0, y0).expect("bilinear x0/y0 in bounds");
1107    let top_right = screenshot_pixel(screenshot, x1, y0).expect("bilinear x1/y0 in bounds");
1108    let bottom_left = screenshot_pixel(screenshot, x0, y1).expect("bilinear x0/y1 in bounds");
1109    let bottom_right = screenshot_pixel(screenshot, x1, y1).expect("bilinear x1/y1 in bounds");
1110
1111    let lerp_channel = |index: usize| {
1112        let top = top_left[index] as f32 * (1.0 - tx) + top_right[index] as f32 * tx;
1113        let bottom = bottom_left[index] as f32 * (1.0 - tx) + bottom_right[index] as f32 * tx;
1114        (top * (1.0 - ty) + bottom * ty).round() as u8
1115    };
1116
1117    [
1118        lerp_channel(0),
1119        lerp_channel(1),
1120        lerp_channel(2),
1121        lerp_channel(3),
1122    ]
1123}
1124
1125fn pixel_difference(before: [u8; 4], after: [u8; 4]) -> u32 {
1126    before
1127        .into_iter()
1128        .zip(after)
1129        .map(|(lhs, rhs)| lhs.abs_diff(rhs) as u32)
1130        .max()
1131        .unwrap_or(0)
1132}
1133
1134/// Parse "label: value" text from a slider label, returning the numeric value.
1135pub fn parse_slider_value(text: &str) -> Option<f32> {
1136    text.split_once(':')
1137        .and_then(|(_, value)| value.trim().parse::<f32>().ok())
1138}
1139
1140/// Scroll down by dragging from `from_y` to `to_y` at `center_x`.
1141pub fn scroll_down(robot: &cranpose::Robot, center_x: f32, from_y: f32, to_y: f32) {
1142    let _ = robot.drag(center_x, from_y, center_x, to_y);
1143    std::thread::sleep(std::time::Duration::from_millis(180));
1144    let _ = robot.wait_for_idle();
1145}
1146
1147/// Scroll up by dragging from `from_y` to `to_y` at `center_x`.
1148pub fn scroll_up(robot: &cranpose::Robot, center_x: f32, from_y: f32, to_y: f32) {
1149    let _ = robot.drag(center_x, from_y, center_x, to_y);
1150    std::thread::sleep(std::time::Duration::from_millis(180));
1151    let _ = robot.wait_for_idle();
1152}
1153
1154/// Check whether a given Y coordinate falls within the visible viewport
1155/// (with 28px margin top and bottom).
1156pub fn y_is_visible(robot: &cranpose::Robot, y: f32) -> bool {
1157    let Some((_, root_y, _, root_h)) = root_bounds(robot) else {
1158        return true;
1159    };
1160    let top = root_y + 28.0;
1161    let bottom = root_y + root_h - 28.0;
1162    y >= top && y <= bottom
1163}
1164
1165/// Configuration for scroll-into-view helpers so we don't need too many arguments.
1166#[derive(Clone, Copy, Debug)]
1167pub struct ScrollConfig {
1168    pub center_x: f32,
1169    pub down_from_y: f32,
1170    pub down_to_y: f32,
1171    pub up_from_y: f32,
1172    pub up_to_y: f32,
1173}
1174
1175/// Scroll until a semantics node with the given `prefix` text is visible.
1176/// Returns bounds + full text `(x, y, w, h, text)`.
1177pub fn scroll_prefix_into_view(
1178    robot: &cranpose::Robot,
1179    prefix: &str,
1180    max_attempts: usize,
1181    cfg: ScrollConfig,
1182) -> Option<(f32, f32, f32, f32, String)> {
1183    for attempt in 0..max_attempts {
1184        if let Some(bounds) = find_text_by_prefix_in_semantics(robot, prefix) {
1185            let center_y = bounds.1 + bounds.3 * 0.5;
1186            if y_is_visible(robot, center_y) {
1187                return Some(bounds);
1188            }
1189            let Some((_, root_y, _, root_h)) = root_bounds(robot) else {
1190                return Some(bounds);
1191            };
1192            let viewport_mid = root_y + root_h * 0.5;
1193            if center_y > viewport_mid {
1194                scroll_down(robot, cfg.center_x, cfg.down_from_y, cfg.down_to_y);
1195            } else {
1196                scroll_up(robot, cfg.center_x, cfg.up_from_y, cfg.up_to_y);
1197            }
1198        } else {
1199            // Not found yet — alternate directions to find it
1200            if attempt % 2 == 0 {
1201                scroll_down(robot, cfg.center_x, cfg.down_from_y, cfg.down_to_y);
1202            } else {
1203                scroll_up(robot, cfg.center_x, cfg.up_from_y, cfg.up_to_y);
1204            }
1205        }
1206    }
1207    None
1208}
1209
1210/// Set a slider to a given fraction [0, 1] and return the parsed value.
1211pub fn set_slider_fraction(
1212    robot: &cranpose::Robot,
1213    prefix: &str,
1214    fraction: f32,
1215    slider_width: f32,
1216    slider_touch_offset_y: f32,
1217    cfg: ScrollConfig,
1218) -> Option<f32> {
1219    let (x, y, _w, h, _) = scroll_prefix_into_view(robot, prefix, 18, cfg)?;
1220    let slider_y = y + h + slider_touch_offset_y;
1221    let left_x = x + 2.0;
1222    let target_x = x + slider_width * fraction.clamp(0.0, 1.0);
1223    let _ = robot.drag(left_x, slider_y, target_x, slider_y);
1224    std::thread::sleep(std::time::Duration::from_millis(120));
1225    let _ = robot.wait_for_idle();
1226    find_text_by_prefix_in_semantics(robot, prefix)
1227        .and_then(|(_, _, _, _, t)| parse_slider_value(&t))
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233    use cranpose::{RobotScreenshot, SemanticRect};
1234
1235    fn semantic_element(
1236        role: &str,
1237        text: Option<&str>,
1238        clickable: bool,
1239        bounds: RectBounds,
1240        children: Vec<SemanticElement>,
1241    ) -> SemanticElement {
1242        SemanticElement {
1243            role: role.to_string(),
1244            text: text.map(ToString::to_string),
1245            clickable,
1246            bounds: SemanticRect {
1247                x: bounds.0,
1248                y: bounds.1,
1249                width: bounds.2,
1250                height: bounds.3,
1251            },
1252            children,
1253        }
1254    }
1255
1256    #[test]
1257    fn overflow_axis_direction_detects_horizontal_overflow() {
1258        let root = (0.0, 0.0, 300.0, 200.0);
1259        let target = (320.0, 20.0, 80.0, 30.0);
1260        assert_eq!(
1261            overflow_axis_direction(target, root),
1262            Some((TabAxis::Horizontal, -1.0))
1263        );
1264    }
1265
1266    #[test]
1267    fn overflow_axis_direction_detects_vertical_overflow() {
1268        let root = (0.0, 0.0, 300.0, 200.0);
1269        let target = (20.0, -60.0, 80.0, 30.0);
1270        assert_eq!(
1271            overflow_axis_direction(target, root),
1272            Some((TabAxis::Vertical, 1.0))
1273        );
1274    }
1275
1276    #[test]
1277    fn find_scroll_anchor_prefers_cross_axis_overlap() {
1278        let root = (0.0, 0.0, 300.0, 200.0);
1279        let target = (320.0, 22.0, 80.0, 28.0);
1280
1281        let same_row = semantic_element(
1282            "Layout",
1283            Some("same-row"),
1284            true,
1285            (120.0, 20.0, 80.0, 30.0),
1286            vec![],
1287        );
1288        let other_row = semantic_element(
1289            "Layout",
1290            Some("other-row"),
1291            true,
1292            (120.0, 130.0, 80.0, 30.0),
1293            vec![],
1294        );
1295        let root_elem = semantic_element("Layout", None, false, root, vec![same_row, other_row]);
1296
1297        let anchor = find_scroll_anchor(&[root_elem], target, root, TabAxis::Horizontal)
1298            .expect("expected anchor");
1299
1300        assert_eq!(anchor, (120.0, 20.0, 80.0, 30.0));
1301    }
1302
1303    #[test]
1304    fn find_button_exact_requires_full_text_match() {
1305        let exact_button = semantic_element(
1306            "Button",
1307            None,
1308            true,
1309            (10.0, 20.0, 90.0, 28.0),
1310            vec![
1311                semantic_element(
1312                    "Text",
1313                    Some("Text"),
1314                    false,
1315                    (14.0, 24.0, 30.0, 20.0),
1316                    vec![],
1317                ),
1318                semantic_element(
1319                    "Text",
1320                    Some("Text Input"),
1321                    false,
1322                    (46.0, 24.0, 48.0, 20.0),
1323                    vec![],
1324                ),
1325            ],
1326        );
1327        let partial_only_button = semantic_element(
1328            "Button",
1329            None,
1330            true,
1331            (120.0, 20.0, 90.0, 28.0),
1332            vec![semantic_element(
1333                "Text",
1334                Some("Text Input"),
1335                false,
1336                (124.0, 24.0, 48.0, 20.0),
1337                vec![],
1338            )],
1339        );
1340
1341        assert_eq!(
1342            find_button_by(&exact_button, "Text", text_equals),
1343            Some((10.0, 20.0, 90.0, 28.0))
1344        );
1345        assert_eq!(
1346            find_button_by(&partial_only_button, "Text", text_equals),
1347            None
1348        );
1349    }
1350
1351    #[test]
1352    fn screenshot_pixel_reads_expected_value() {
1353        let screenshot = RobotScreenshot {
1354            width: 2,
1355            height: 1,
1356            logical_width: 2.0,
1357            logical_height: 1.0,
1358            pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
1359        };
1360        assert_eq!(screenshot_pixel(&screenshot, 1, 0), Some([5, 6, 7, 8]));
1361    }
1362
1363    #[test]
1364    fn crop_screenshot_extracts_region() {
1365        let screenshot = RobotScreenshot {
1366            width: 3,
1367            height: 2,
1368            logical_width: 3.0,
1369            logical_height: 2.0,
1370            pixels: vec![
1371                1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255, 13, 14, 15, 255, 16, 17,
1372                18, 255,
1373            ],
1374        };
1375        let cropped = crop_screenshot(&screenshot, 1, 0, 2, 2).expect("crop");
1376        assert_eq!(cropped.width, 2);
1377        assert_eq!(cropped.height, 2);
1378        assert_eq!(cropped.logical_width, 2.0);
1379        assert_eq!(cropped.logical_height, 2.0);
1380        assert_eq!(
1381            cropped.pixels,
1382            vec![4, 5, 6, 255, 7, 8, 9, 255, 13, 14, 15, 255, 16, 17, 18, 255]
1383        );
1384    }
1385
1386    #[test]
1387    fn normalize_screenshot_region_preserves_pixel_grid_at_native_size() {
1388        let screenshot = RobotScreenshot {
1389            width: 2,
1390            height: 2,
1391            logical_width: 2.0,
1392            logical_height: 2.0,
1393            pixels: vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255],
1394        };
1395
1396        let normalized =
1397            normalize_screenshot_region(&screenshot, (0.0, 0.0, 2.0, 2.0), 2, 2).expect("norm");
1398
1399        assert_eq!(normalized.width, screenshot.width);
1400        assert_eq!(normalized.height, screenshot.height);
1401        assert_eq!(normalized.logical_width, screenshot.logical_width);
1402        assert_eq!(normalized.logical_height, screenshot.logical_height);
1403        assert_eq!(normalized.pixels, screenshot.pixels);
1404    }
1405
1406    #[test]
1407    fn changed_pixel_count_in_region_uses_logical_coordinates() {
1408        let before = RobotScreenshot {
1409            width: 4,
1410            height: 4,
1411            logical_width: 2.0,
1412            logical_height: 2.0,
1413            pixels: vec![
1414                0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1415                0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1416                0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255,
1417            ],
1418        };
1419        let mut after = before.clone();
1420        for y in 2..4 {
1421            for x in 2..4 {
1422                let idx = ((y * after.width + x) * 4) as usize;
1423                after.pixels[idx] = 255;
1424            }
1425        }
1426
1427        assert_eq!(
1428            changed_pixel_count_in_region(&before, &after, (1.0, 1.0, 1.0, 1.0), 1),
1429            4
1430        );
1431    }
1432
1433    #[test]
1434    fn sample_screenshot_pixel_logical_maps_scaled_capture() {
1435        let screenshot = RobotScreenshot {
1436            width: 4,
1437            height: 4,
1438            logical_width: 2.0,
1439            logical_height: 2.0,
1440            pixels: vec![
1441                1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255, 5, 0, 0, 255, 6, 0, 0, 255,
1442                7, 0, 0, 255, 8, 0, 0, 255, 9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0,
1443                255, 13, 0, 0, 255, 14, 0, 0, 255, 15, 0, 0, 255, 16, 0, 0, 255,
1444            ],
1445        };
1446
1447        assert_eq!(
1448            sample_screenshot_pixel_logical(&screenshot, 1.25, 1.25),
1449            Some([11, 0, 0, 255])
1450        );
1451    }
1452
1453    #[test]
1454    fn screenshot_difference_stats_reports_first_difference() {
1455        let before = RobotScreenshot {
1456            width: 2,
1457            height: 1,
1458            logical_width: 2.0,
1459            logical_height: 1.0,
1460            pixels: vec![10, 20, 30, 255, 1, 2, 3, 255],
1461        };
1462        let after = RobotScreenshot {
1463            width: 2,
1464            height: 1,
1465            logical_width: 2.0,
1466            logical_height: 1.0,
1467            pixels: vec![10, 20, 30, 255, 4, 8, 3, 200],
1468        };
1469
1470        let stats = screenshot_difference_stats(&before, &after, 3).expect("stats");
1471
1472        assert_eq!(stats.differing_pixels, 1);
1473        assert_eq!(stats.max_difference, 55);
1474        assert_eq!(
1475            stats.first_difference,
1476            Some(ScreenshotPixelDifference {
1477                x: 1,
1478                y: 0,
1479                before: [1, 2, 3, 255],
1480                after: [4, 8, 3, 200],
1481                difference: 55,
1482            })
1483        );
1484    }
1485}