1use 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
13impl 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
42pub 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
67pub 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
89pub 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
96pub fn has_text(elem: &SemanticElement, text: &str) -> bool {
98 has_text_by(elem, text, text_contains)
99}
100
101pub fn find_button(elem: &SemanticElement, text: &str) -> Option<(f32, f32, f32, f32)> {
106 find_button_by(elem, text, text_contains)
107}
108
109pub 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
115pub 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
142pub 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
156pub 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
181pub 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
197pub 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
206pub 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
278pub 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
294pub 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
794pub 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
838pub 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
904pub 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
967pub 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
1007pub 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
1052pub 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
1076pub 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
1156pub fn parse_slider_value(text: &str) -> Option<f32> {
1158 text.split_once(':')
1159 .and_then(|(_, value)| value.trim().parse::<f32>().ok())
1160}
1161
1162pub 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
1169pub 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
1176pub 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#[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
1239pub 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
1261pub 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
1283pub 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}