1use serde::Serialize;
18use thiserror::Error;
19
20use crate::geometry::{Point, Shape, Size};
21use crate::report::{Command, Report};
22use crate::session::SelectionRecord;
23
24pub const SCORE_FLOOR: f64 = 0.9;
28
29pub const AMBIGUITY_GAP: f64 = 0.03;
32
33#[derive(Debug, Clone)]
35pub struct GrayImage {
36 pub w: usize,
37 pub h: usize,
38 pub px: Vec<f32>,
39}
40
41impl GrayImage {
42 pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Self {
44 assert_eq!(rgba.len(), w * h * 4, "rgba buffer matches dimensions");
45 let px = rgba
46 .chunks_exact(4)
47 .map(|p| luma(p[0], p[1], p[2]))
48 .collect();
49 Self { w, h, px }
50 }
51}
52
53fn luma(r: u8, g: u8, b: u8) -> f32 {
54 (0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b)) / 255.0
55}
56
57pub const MASK_ALPHA_FLOOR: u8 = 128;
61
62#[derive(Debug, Clone)]
66pub struct Template {
67 pub gray: GrayImage,
68 pub mask: Vec<bool>,
69}
70
71impl Template {
72 pub fn from_rgba(w: usize, h: usize, rgba: &[u8]) -> Self {
74 let gray = GrayImage::from_rgba(w, h, rgba);
75 let mask = rgba
76 .chunks_exact(4)
77 .map(|p| p[3] >= MASK_ALPHA_FLOOR)
78 .collect();
79 Self { gray, mask }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct Located {
86 pub x: i32,
88 pub y: i32,
89 pub score: f64,
91 pub runner_up: f64,
94 pub ambiguous: bool,
97}
98
99#[derive(Debug, Error, PartialEq, Eq)]
100pub enum LocateError {
101 #[error("the crop is larger than the capture it is searched in")]
102 TemplateLargerThanScreen,
103 #[error("the crop has no visible pixels to match")]
104 EmptyTemplate,
105 #[error("the crop is a flat color, which matches anywhere rather than somewhere")]
106 FlatTemplate,
107 #[error("the crop does not fit in the {frame:?} frame at {at:?}")]
108 OutOfFrame { at: Point, frame: Size },
109}
110
111#[derive(Debug, Clone)]
119pub struct TemplateStats {
120 coords: Vec<(usize, usize)>,
121 values: Vec<f64>,
122 mean: f64,
123 var: f64,
124 span: Size,
129}
130
131impl TemplateStats {
132 pub fn prepare(tpl: &Template) -> Result<Self, LocateError> {
134 template_stats(tpl)
135 }
136
137 #[must_use]
139 pub const fn span(&self) -> Size {
140 self.span
141 }
142
143 pub fn score_at(&self, screen: &GrayImage, at: Point) -> Result<f64, LocateError> {
150 let fits = at.x >= 0
151 && at.y >= 0
152 && (at.x as usize).saturating_add(self.span.w as usize) <= screen.w
153 && (at.y as usize).saturating_add(self.span.h as usize) <= screen.h;
154 if !fits {
155 return Err(LocateError::OutOfFrame {
156 at,
157 frame: Size::new(screen.w as i32, screen.h as i32),
158 });
159 }
160 Ok(ncc_at(screen, self, at.x as usize, at.y as usize))
161 }
162}
163
164fn template_stats(tpl: &Template) -> Result<TemplateStats, LocateError> {
165 let mut coords = Vec::new();
166 let mut values = Vec::new();
167 for ty in 0..tpl.gray.h {
168 for tx in 0..tpl.gray.w {
169 if !tpl.mask[ty * tpl.gray.w + tx] {
170 continue;
171 }
172 coords.push((tx, ty));
173 values.push(f64::from(tpl.gray.px[ty * tpl.gray.w + tx]));
174 }
175 }
176 if coords.is_empty() {
177 return Err(LocateError::EmptyTemplate);
178 }
179 let n = values.len() as f64;
180 let mean = values.iter().sum::<f64>() / n;
181 let var = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>();
182 if var <= f64::EPSILON {
183 return Err(LocateError::FlatTemplate);
184 }
185 let span = Size::new(
186 coords.iter().map(|c| c.0).max().unwrap_or(0) as i32 + 1,
187 coords.iter().map(|c| c.1).max().unwrap_or(0) as i32 + 1,
188 );
189 Ok(TemplateStats {
190 coords,
191 values,
192 mean,
193 var,
194 span,
195 })
196}
197
198fn ncc_at(screen: &GrayImage, stats: &TemplateStats, ox: usize, oy: usize) -> f64 {
200 let n = stats.values.len() as f64;
201 let mut sum_screen = 0.0;
202 let mut sum_screen_sq = 0.0;
203 let mut sum_cross = 0.0;
204 for (i, &(tx, ty)) in stats.coords.iter().enumerate() {
205 let s = f64::from(screen.px[(oy + ty) * screen.w + ox + tx]);
206 sum_screen += s;
207 sum_screen_sq += s * s;
208 sum_cross += stats.values[i] * s;
209 }
210 let mean_screen = sum_screen / n;
211 let var_screen = sum_screen_sq - n * mean_screen * mean_screen;
212 if var_screen <= f64::EPSILON {
213 return 0.0;
214 }
215 let cov = sum_cross - n * stats.mean * mean_screen;
216 cov / (stats.var * var_screen).sqrt()
217}
218
219fn best_in_window(
221 screen: &GrayImage,
222 stats: &TemplateStats,
223 x0: usize,
224 y0: usize,
225 x1: usize,
226 y1: usize,
227) -> (usize, usize, f64) {
228 let mut best = (x0, y0, f64::from(f32::MIN));
229 for oy in y0..=y1 {
230 for ox in x0..=x1 {
231 let score = ncc_at(screen, stats, ox, oy);
232 if score > best.2 {
233 best = (ox, oy, score);
234 }
235 }
236 }
237 best
238}
239
240pub fn locate(
243 screen: &GrayImage,
244 tpl: &Template,
245 hint: Option<Point>,
246) -> Result<Located, LocateError> {
247 let (tw, th) = (tpl.gray.w, tpl.gray.h);
248 if tw > screen.w || th > screen.h || tw == 0 || th == 0 {
249 return Err(LocateError::TemplateLargerThanScreen);
250 }
251 let stats = template_stats(tpl)?;
252 let max_x = screen.w - tw;
253 let max_y = screen.h - th;
254
255 let factor = match tw.min(th) {
260 0..16 => 1,
261 16..32 => 2,
262 _ => 4,
263 };
264 let mut candidates = coarse_candidates(screen, tpl, factor)?;
265 if let Some(p) = hint {
266 candidates.push((
267 p.x.clamp(0, max_x as i32) as usize,
268 p.y.clamp(0, max_y as i32) as usize,
269 ));
270 }
271
272 let margin = factor * 2;
274 let mut refined: Vec<(usize, usize, f64)> = candidates
275 .iter()
276 .map(|&(cx, cy)| {
277 let x0 = cx.saturating_sub(margin);
278 let y0 = cy.saturating_sub(margin);
279 best_in_window(
280 screen,
281 &stats,
282 x0,
283 y0,
284 (cx + margin).min(max_x),
285 (cy + margin).min(max_y),
286 )
287 })
288 .collect();
289 refined.sort_by(|a, b| b.2.total_cmp(&a.2));
290 let best = refined[0];
291
292 let separated = |x: usize, y: usize| x.abs_diff(best.0) > tw / 2 || y.abs_diff(best.1) > th / 2;
295 let runner_up = refined[1..]
296 .iter()
297 .filter(|&&(x, y, _)| separated(x, y))
298 .map(|&(_, _, s)| s)
299 .fold(-1.0f64, f64::max);
300
301 let ambiguous = runner_up >= SCORE_FLOOR && best.2 - runner_up <= AMBIGUITY_GAP;
302 Ok(Located {
303 x: best.0 as i32,
304 y: best.1 as i32,
305 score: best.2,
306 runner_up,
307 ambiguous,
308 })
309}
310
311fn coarse_candidates(
316 screen: &GrayImage,
317 tpl: &Template,
318 factor: usize,
319) -> Result<Vec<(usize, usize)>, LocateError> {
320 let (small_screen, small_stats) = if factor == 1 {
321 (screen.clone(), template_stats(tpl)?)
322 } else {
323 let small_tpl = downsample_template(tpl, factor);
324 (downsample(screen, factor), template_stats(&small_tpl)?)
325 };
326 let tw = small_stats.coords.iter().map(|c| c.0).max().unwrap_or(0) + 1;
327 let th = small_stats.coords.iter().map(|c| c.1).max().unwrap_or(0) + 1;
328 if tw > small_screen.w || th > small_screen.h {
329 return Err(LocateError::TemplateLargerThanScreen);
330 }
331 let max_x = small_screen.w - tw;
332 let max_y = small_screen.h - th;
333
334 let mut scores = vec![0.0f64; (max_x + 1) * (max_y + 1)];
335 for oy in 0..=max_y {
336 for ox in 0..=max_x {
337 scores[oy * (max_x + 1) + ox] = ncc_at(&small_screen, &small_stats, ox, oy);
338 }
339 }
340 let mut out: Vec<(usize, usize)> = Vec::new();
345 for _ in 0..3 {
346 let mut best = (0usize, 0usize, f64::from(f32::MIN));
347 for oy in 0..=max_y {
348 for ox in 0..=max_x {
349 let suppressed = out.iter().any(|&(px, py)| {
350 ox.abs_diff(px / factor) <= tw / 2 && oy.abs_diff(py / factor) <= th / 2
351 });
352 if suppressed {
353 continue;
354 }
355 let s = scores[oy * (max_x + 1) + ox];
356 if s > best.2 {
357 best = (ox, oy, s);
358 }
359 }
360 }
361 if best.2 <= f64::from(f32::MIN) {
362 break;
363 }
364 out.push((best.0 * factor, best.1 * factor));
365 }
366 Ok(out)
367}
368
369fn downsample(img: &GrayImage, factor: usize) -> GrayImage {
371 let w = img.w / factor;
372 let h = img.h / factor;
373 let mut px = Vec::with_capacity(w * h);
374 for y in 0..h {
375 for x in 0..w {
376 let mut sum = 0.0f32;
377 for sy in 0..factor {
378 for sx in 0..factor {
379 sum += img.px[(y * factor + sy) * img.w + x * factor + sx];
380 }
381 }
382 px.push(sum / (factor * factor) as f32);
383 }
384 }
385 GrayImage { w, h, px }
386}
387
388fn downsample_template(tpl: &Template, factor: usize) -> Template {
391 let w = tpl.gray.w / factor;
392 let h = tpl.gray.h / factor;
393 let mut px = Vec::with_capacity(w * h);
394 let mut mask = Vec::with_capacity(w * h);
395 for y in 0..h {
396 for x in 0..w {
397 let mut sum = 0.0f32;
398 let mut n = 0usize;
399 for sy in 0..factor {
400 for sx in 0..factor {
401 let i = (y * factor + sy) * tpl.gray.w + x * factor + sx;
402 if !tpl.mask[i] {
403 continue;
404 }
405 sum += tpl.gray.px[i];
406 n += 1;
407 }
408 }
409 let keep = n * 2 >= factor * factor;
410 mask.push(keep);
411 px.push(if keep { sum / n as f32 } else { 0.0 });
412 }
413 }
414 Template {
415 gray: GrayImage { w, h, px },
416 mask,
417 }
418}
419
420pub fn crop_origin(shape: &Shape, rot_deg: i32, frame: Size) -> Point {
423 let bbox = shape.rotated_bbox(rot_deg);
424 Point::new(bbox.x.max(0).min(frame.w), bbox.y.max(0).min(frame.h))
425}
426
427pub struct Relocation {
429 pub crop_origin: Point,
431 pub outcome: Result<Located, LocateError>,
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
436pub struct Delta {
437 pub dx: i32,
438 pub dy: i32,
439}
440
441#[derive(Debug, Clone, PartialEq, Serialize)]
443pub struct FindResult {
444 pub index: usize,
445 pub label: String,
446 pub monitor: usize,
447 pub found: bool,
448 pub ambiguous: bool,
449 pub score: f64,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub reason: Option<String>,
452 pub old_px: Shape,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub new_px: Option<Shape>,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub new_global_px: Option<Shape>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub delta: Option<Delta>,
459}
460
461#[must_use]
467pub fn all_relocated(results: &[FindResult]) -> bool {
468 !results.is_empty() && results.iter().all(|r| r.found && !r.ambiguous)
469}
470
471pub fn report(
481 attempts: &[(usize, &SelectionRecord, Relocation)],
482 captured_utc: String,
483) -> Report<FindResult> {
484 let results: Vec<FindResult> = attempts
485 .iter()
486 .map(|(index, record, reloc)| {
487 let index = *index;
488 let base = FindResult {
489 index,
490 label: record.label.clone(),
491 monitor: record.monitor,
492 found: false,
493 ambiguous: false,
494 score: 0.0,
495 reason: None,
496 old_px: record.px.clone(),
497 new_px: None,
498 new_global_px: None,
499 delta: None,
500 };
501 match &reloc.outcome {
502 Err(e) => FindResult {
503 reason: Some(e.to_string()),
504 ..base
505 },
506 Ok(loc) => {
507 let found = loc.score >= SCORE_FLOOR;
508 let moved = found && !loc.ambiguous;
509 let dx = loc.x - reloc.crop_origin.x;
510 let dy = loc.y - reloc.crop_origin.y;
511 FindResult {
512 found,
513 ambiguous: found && loc.ambiguous,
514 score: loc.score,
515 new_px: moved.then(|| record.px.translated(dx, dy)),
516 new_global_px: moved.then(|| record.global_px.translated(dx, dy)),
517 delta: moved.then_some(Delta { dx, dy }),
518 ..base
519 }
520 }
521 }
522 })
523 .collect();
524 let ok = all_relocated(&results);
525 Report::captured(Command::Find, captured_utc, ok, results)
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::geometry::Rect;
532
533 fn textured(w: usize, h: usize, seed: u32) -> GrayImage {
538 let mut state = seed | 1;
539 let mut px: Vec<f32> = (0..w * h)
540 .map(|_| {
541 state ^= state << 13;
543 state ^= state >> 17;
544 state ^= state << 5;
545 (state % 1000) as f32 / 1000.0
546 })
547 .collect();
548 for _ in 0..2 {
549 px = blur3(&px, w, h);
550 }
551 GrayImage { w, h, px }
552 }
553
554 fn blur3(px: &[f32], w: usize, h: usize) -> Vec<f32> {
555 let mut out = Vec::with_capacity(w * h);
556 for y in 0..h {
557 for x in 0..w {
558 out.push(blurred_at(px, w, h, x, y));
559 }
560 }
561 out
562 }
563
564 fn blurred_at(px: &[f32], width: usize, height: usize, col: usize, row: usize) -> f32 {
565 let mut sum = 0.0f32;
566 let mut count = 0.0f32;
567 for dy in -1i32..=1 {
568 for dx in -1i32..=1 {
569 let nx = col as i32 + dx;
570 let ny = row as i32 + dy;
571 if nx < 0 || ny < 0 || nx >= width as i32 || ny >= height as i32 {
572 continue;
573 }
574 sum += px[ny as usize * width + nx as usize];
575 count += 1.0;
576 }
577 }
578 sum / count
579 }
580
581 fn cut(img: &GrayImage, x: usize, y: usize, w: usize, h: usize) -> Template {
582 let mut px = Vec::with_capacity(w * h);
583 for ty in 0..h {
584 for tx in 0..w {
585 px.push(img.px[(y + ty) * img.w + x + tx]);
586 }
587 }
588 Template {
589 gray: GrayImage { w, h, px },
590 mask: vec![true; w * h],
591 }
592 }
593
594 #[test]
595 fn a_cut_template_is_found_where_it_was_cut() {
596 let screen = textured(200, 150, 7);
597 let tpl = cut(&screen, 63, 41, 24, 18);
598 let loc = locate(&screen, &tpl, Some(Point::new(5, 5))).unwrap();
600 assert_eq!((loc.x, loc.y), (63, 41));
601 assert!(loc.score > 0.999, "exact cut scores ~1, got {}", loc.score);
602 assert!(!loc.ambiguous);
603 }
604
605 #[test]
606 fn a_large_template_takes_the_pyramid_path_and_still_lands_exactly() {
607 let screen = textured(400, 300, 11);
608 let tpl = cut(&screen, 137, 92, 80, 64);
609 let loc = locate(&screen, &tpl, None).unwrap();
610 assert_eq!((loc.x, loc.y), (137, 92));
611 assert!(loc.score > 0.999);
612 }
613
614 #[test]
615 fn a_masked_template_ignores_its_transparent_corners() {
616 let screen = textured(160, 120, 23);
617 let mut tpl = cut(&screen, 50, 40, 32, 32);
618 for (i, m) in tpl.mask.iter_mut().enumerate() {
621 let (x, y) = (i % 32, i / 32);
622 let (cx, cy) = (16i32, 16i32);
623 let (dx, dy) = (x as i32 - cx, y as i32 - cy);
624 if dx * dx + dy * dy > 16 * 16 {
625 *m = false;
626 }
627 }
628 for (i, p) in tpl.gray.px.iter_mut().enumerate() {
629 if !tpl.mask[i] {
630 *p = 1.0 - *p;
631 }
632 }
633 let loc = locate(&screen, &tpl, None).unwrap();
634 assert_eq!((loc.x, loc.y), (50, 40));
635 assert!(loc.score > 0.999);
636 }
637
638 #[test]
639 fn scoring_at_a_known_spot_agrees_with_searching_for_it() {
640 let screen = textured(160, 120, 31);
644 let tpl = cut(&screen, 50, 40, 24, 24);
645 let stats = TemplateStats::prepare(&tpl).unwrap();
646
647 let fixed = stats.score_at(&screen, Point::new(50, 40)).unwrap();
648 let searched = locate(&screen, &tpl, None).unwrap();
649 assert!(
650 (fixed - searched.score).abs() < 1e-9,
651 "{fixed} vs {searched:?}"
652 );
653 assert!(fixed > 0.999);
654 }
655
656 #[test]
657 fn scoring_the_wrong_spot_scores_low_without_searching_for_a_better_one() {
658 let screen = textured(160, 120, 31);
659 let stats = TemplateStats::prepare(&cut(&screen, 50, 40, 24, 24)).unwrap();
660 let elsewhere = stats.score_at(&screen, Point::new(10, 10)).unwrap();
661 assert!(
662 elsewhere < SCORE_FLOOR,
663 "fixed-location scoring reports what is there, got {elsewhere}"
664 );
665 }
666
667 #[test]
668 fn a_placement_off_the_frame_is_refused_rather_than_indexing_past_it() {
669 let screen = textured(64, 64, 7);
672 let stats = TemplateStats::prepare(&cut(&screen, 0, 0, 24, 24)).unwrap();
673 assert_eq!(stats.span(), Size::new(24, 24));
674
675 for bad in [Point::new(-1, 0), Point::new(0, -1), Point::new(41, 0)] {
676 assert_eq!(
677 stats.score_at(&screen, bad).unwrap_err(),
678 LocateError::OutOfFrame {
679 at: bad,
680 frame: Size::new(64, 64),
681 },
682 "at {bad:?}"
683 );
684 }
685 assert!(stats.score_at(&screen, Point::new(40, 40)).is_ok());
687 }
688
689 #[test]
690 fn preparing_is_where_an_unusable_crop_is_refused() {
691 let flat = Template {
694 gray: GrayImage {
695 w: 4,
696 h: 4,
697 px: vec![0.5; 16],
698 },
699 mask: vec![true; 16],
700 };
701 assert_eq!(
702 TemplateStats::prepare(&flat).unwrap_err(),
703 LocateError::FlatTemplate
704 );
705
706 let empty = Template {
707 gray: GrayImage {
708 w: 4,
709 h: 4,
710 px: vec![0.5; 16],
711 },
712 mask: vec![false; 16],
713 };
714 assert_eq!(
715 TemplateStats::prepare(&empty).unwrap_err(),
716 LocateError::EmptyTemplate
717 );
718 }
719
720 #[test]
721 fn changed_pixels_score_below_the_floor() {
722 let screen = textured(160, 120, 31);
723 let tpl = cut(&screen, 50, 40, 24, 24);
724 let elsewhere = textured(160, 120, 97);
725 let loc = locate(&elsewhere, &tpl, None).unwrap();
726 assert!(
727 loc.score < SCORE_FLOOR,
728 "unrelated content must not match, got {}",
729 loc.score
730 );
731 }
732
733 #[test]
734 fn a_duplicated_region_is_ambiguous_not_silently_resolved() {
735 let mut screen = textured(300, 100, 43);
736 for ty in 0..24 {
738 for tx in 0..24 {
739 let v = screen.px[(30 + ty) * 300 + 20 + tx];
740 screen.px[(30 + ty) * 300 + 200 + tx] = v;
741 }
742 }
743 let tpl = cut(&screen, 20, 30, 24, 24);
744 let loc = locate(&screen, &tpl, None).unwrap();
745 assert!(loc.ambiguous, "two identical regions: {loc:?}");
746 assert!(loc.runner_up > 0.999);
747 }
748
749 #[test]
750 fn degenerate_templates_are_refused_with_reasons() {
751 let screen = textured(50, 50, 3);
752 let flat = Template {
753 gray: GrayImage {
754 w: 8,
755 h: 8,
756 px: vec![0.5; 64],
757 },
758 mask: vec![true; 64],
759 };
760 assert_eq!(
761 locate(&screen, &flat, None).unwrap_err(),
762 LocateError::FlatTemplate
763 );
764 let empty = Template {
765 gray: GrayImage {
766 w: 8,
767 h: 8,
768 px: vec![0.5; 64],
769 },
770 mask: vec![false; 64],
771 };
772 assert_eq!(
773 locate(&screen, &empty, None).unwrap_err(),
774 LocateError::EmptyTemplate
775 );
776 let huge = cut(&textured(80, 80, 5), 0, 0, 80, 80);
777 assert_eq!(
778 locate(&screen, &huge, None).unwrap_err(),
779 LocateError::TemplateLargerThanScreen
780 );
781 }
782
783 #[test]
784 fn rgba_conversion_masks_transparency_and_weights_luma() {
785 let rgba = [0u8, 255, 0, 255, 255, 255, 255, 0];
787 let tpl = Template::from_rgba(2, 1, &rgba);
788 assert!(tpl.mask[0] && !tpl.mask[1]);
789 assert!((tpl.gray.px[0] - 0.587).abs() < 1e-4);
790 assert!((tpl.gray.px[1] - 1.0).abs() < 1e-4);
791 }
792
793 #[test]
794 fn crop_origin_is_the_clipped_rotated_bbox() {
795 let frame = Size::new(1920, 1080);
796 assert_eq!(
797 crop_origin(&Shape::Rect(Rect::new(100, 50, 40, 30)), 0, frame),
798 Point::new(100, 50)
799 );
800 assert_eq!(
803 crop_origin(&Shape::Rect(Rect::new(100, 50, 40, 30)), 90, frame),
804 Point::new(105, 45)
805 );
806 assert_eq!(
808 crop_origin(&Shape::Rect(Rect::new(-20, -10, 40, 30)), 0, frame),
809 Point::new(0, 0)
810 );
811 }
812
813 fn session_of_one() -> crate::session::SessionFile {
814 use crate::selection::Selection;
815 use crate::session::{MonitorRecord, SessionFile};
816 let mut sel = Selection::new(Shape::Rect(Rect::new(10, 20, 30, 40)), 0);
817 sel.label = "submit".into();
818 SessionFile::build(
819 "test",
820 "2026-07-27T00:00:00Z".into(),
821 vec![MonitorRecord {
822 index: 0,
823 name: "Main".into(),
824 primary: true,
825 origin_px: Point::new(100, 0),
826 size_px: Size::new(1920, 1080),
827 scale: 1.0,
828 }],
829 &[sel],
830 &["c0.png".into()],
831 None,
832 )
833 }
834
835 #[test]
836 fn report_translates_found_selections_and_flags_the_rest() {
837 let session = session_of_one();
838 let found = report(
839 &[(
840 0,
841 &session.selections[0],
842 Relocation {
843 crop_origin: Point::new(10, 20),
844 outcome: Ok(Located {
845 x: 14,
846 y: 8,
847 score: 0.97,
848 runner_up: 0.1,
849 ambiguous: false,
850 }),
851 },
852 )],
853 "2026-07-27T01:00:00Z".into(),
854 );
855 assert!(found.ok);
856 let r = &found.results[0];
857 assert_eq!(r.delta, Some(Delta { dx: 4, dy: -12 }));
858 assert_eq!(r.new_px, Some(Shape::Rect(Rect::new(14, 8, 30, 40))));
859 assert_eq!(
860 r.new_global_px,
861 Some(Shape::Rect(Rect::new(114, 8, 30, 40))),
862 "global keeps the monitor origin offset"
863 );
864
865 let miss = report(
866 &[(
867 0,
868 &session.selections[0],
869 Relocation {
870 crop_origin: Point::new(10, 20),
871 outcome: Ok(Located {
872 x: 0,
873 y: 0,
874 score: 0.4,
875 runner_up: 0.1,
876 ambiguous: false,
877 }),
878 },
879 )],
880 "t".into(),
881 );
882 assert!(!miss.ok);
883 assert!(!miss.results[0].found);
884 assert!(miss.results[0].new_px.is_none());
885
886 let ambiguous = report(
887 &[(
888 0,
889 &session.selections[0],
890 Relocation {
891 crop_origin: Point::new(10, 20),
892 outcome: Ok(Located {
893 x: 14,
894 y: 8,
895 score: 0.99,
896 runner_up: 0.98,
897 ambiguous: true,
898 }),
899 },
900 )],
901 "t".into(),
902 );
903 assert!(!ambiguous.ok);
904 assert!(ambiguous.results[0].found && ambiguous.results[0].ambiguous);
905 assert!(
906 ambiguous.results[0].new_px.is_none(),
907 "an ambiguous match must not hand out coordinates"
908 );
909
910 let errored = report(
911 &[(
912 0,
913 &session.selections[0],
914 Relocation {
915 crop_origin: Point::new(10, 20),
916 outcome: Err(LocateError::FlatTemplate),
917 },
918 )],
919 "t".into(),
920 );
921 assert!(!errored.ok);
922 assert!(
923 errored.results[0]
924 .reason
925 .as_deref()
926 .unwrap()
927 .contains("flat color")
928 );
929 }
930
931 #[test]
932 fn find_report_json_shape_is_stable() {
933 let session = session_of_one();
934 let rep = report(
935 &[(
936 0,
937 &session.selections[0],
938 Relocation {
939 crop_origin: Point::new(10, 20),
940 outcome: Ok(Located {
941 x: 10,
942 y: 20,
943 score: 1.0,
944 runner_up: 0.0,
945 ambiguous: false,
946 }),
947 },
948 )],
949 "2026-07-27T01:00:00Z".into(),
950 );
951 let json = serde_json::to_value(&rep).unwrap();
952 assert_eq!(json["schema"], 2);
953 assert_eq!(json["command"], "find");
954 assert_eq!(json["captured_utc"], "2026-07-27T01:00:00Z");
955 assert_eq!(json["ok"], true, "all_relocated is spelled ok now");
956 assert_eq!(json["results"][0]["label"], "submit");
957 assert_eq!(json["results"][0]["delta"]["dx"], 0);
958 assert_eq!(json["results"][0]["new_px"]["x"], 10);
959 assert!(json["results"][0].get("reason").is_none());
960 assert!(
961 json["results"][0]["found"].as_bool().unwrap(),
962 "the row keeps its own answer; ok is the aggregate over rows"
963 );
964 }
965}