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