Skip to main content

pixelcoords_core/
wait.rs

1//! Blocking until a region matches or stops matching — the decisions
2//! behind `pixelcoords wait`.
3//!
4//! The loop itself lives in the binary, because polling needs a capture.
5//! What lives here is everything that decides *when it ends*, and the
6//! reason it lives here is that a clock would otherwise be load-bearing:
7//! `--timeout` is turned into a **poll budget** once, up front, so the
8//! loop counts rather than consults the time.
9//!
10//! That is not only about testability, though it does mean `wait` needs
11//! no `Clock` trait and no injected sleep. A wall-clock deadline gives
12//! the UI *fewer* chances exactly when the machine is slowest, because
13//! more of the deadline goes to capturing — backwards for a
14//! synchronization primitive. A budget gives the same number of chances
15//! everywhere.
16
17use std::time::Duration;
18
19use serde::Serialize;
20use thiserror::Error;
21
22/// One watched region's final state — a row of `wait`'s report.
23#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct RegionWatch {
25    /// Index into `session.selections` — this row's identity.
26    pub index: usize,
27    pub label: String,
28    pub monitor: usize,
29    /// Correlation with the saved crop at the last poll.
30    pub score: f64,
31    /// Whether that score cleared the floor. Reported per region because
32    /// the aggregate cannot say *which* one held things up.
33    pub matching: bool,
34}
35
36/// What `wait` is waiting for.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Condition {
39    /// Every targeted region matches its saved crop again.
40    Match,
41    /// Any targeted region has stopped matching — "tell me when something
42    /// happens here".
43    Change,
44}
45
46#[derive(Debug, Error, PartialEq, Eq)]
47pub enum WaitError {
48    #[error("--interval 0 would poll as fast as capture allows — pass a nonzero interval")]
49    ZeroInterval,
50    #[error(
51        "--interval {interval:?} is longer than --timeout {timeout:?}, so nothing \
52         would be polled twice — shorten the interval or lengthen the timeout"
53    )]
54    IntervalExceedsTimeout {
55        interval: Duration,
56        timeout: Duration,
57    },
58}
59
60/// How many polls a timeout allows.
61///
62/// The first poll is immediate, so `30s` at `500ms` allows 61: one at
63/// zero, then sixty more. Capture time is deliberately *not* counted —
64/// see the module note, and say so in the docs, because it means the wall
65/// clock exceeds `--timeout` by roughly the cost of the captures.
66pub fn poll_budget(timeout: Duration, interval: Duration) -> Result<u32, WaitError> {
67    if interval.is_zero() {
68        return Err(WaitError::ZeroInterval);
69    }
70    if interval > timeout {
71        return Err(WaitError::IntervalExceedsTimeout { interval, timeout });
72    }
73    let spans = timeout.as_millis() / interval.as_millis();
74    // Saturating rather than wrapping: a 2m timeout at 1ms is 120_001
75    // polls, well inside u32, and nothing sensible reaches the ceiling.
76    Ok(u32::try_from(spans).unwrap_or(u32::MAX).saturating_add(1))
77}
78
79/// Whether one poll's scores end the wait.
80///
81/// `match` needs every region at or above the floor; `change` fires on
82/// the first region below it. Those are the semantics that make each verb
83/// useful: waiting for a screen to settle means all of it, and waiting
84/// for something to happen means any of it.
85///
86/// No regions satisfies neither — a wait that verified nothing has not
87/// succeeded.
88#[must_use]
89pub fn satisfied(condition: Condition, scores: &[f64], min_score: f64) -> bool {
90    if scores.is_empty() {
91        return false;
92    }
93    match condition {
94        Condition::Match => scores.iter().all(|s| *s >= min_score),
95        Condition::Change => scores.iter().any(|s| *s < min_score),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn the_documented_budget_is_the_one_computed() {
105        // The number the docs promise, so the promise is checked.
106        assert_eq!(
107            poll_budget(Duration::from_secs(30), Duration::from_millis(500)).unwrap(),
108            61,
109            "one immediate poll, then sixty"
110        );
111    }
112
113    #[test]
114    fn an_interval_equal_to_the_timeout_still_polls_twice() {
115        assert_eq!(
116            poll_budget(Duration::from_secs(5), Duration::from_secs(5)).unwrap(),
117            2,
118            "once now, once at the deadline"
119        );
120    }
121
122    #[test]
123    fn a_ragged_division_keeps_the_polls_that_fit() {
124        // 1s / 300ms = 3 whole intervals, plus the immediate one.
125        assert_eq!(
126            poll_budget(Duration::from_secs(1), Duration::from_millis(300)).unwrap(),
127            4
128        );
129    }
130
131    #[test]
132    fn a_zero_interval_is_refused_rather_than_spinning() {
133        assert_eq!(
134            poll_budget(Duration::from_secs(1), Duration::ZERO).unwrap_err(),
135            WaitError::ZeroInterval
136        );
137    }
138
139    #[test]
140    fn an_interval_past_the_timeout_is_refused_as_a_mistake() {
141        let err = poll_budget(Duration::from_secs(1), Duration::from_secs(2)).unwrap_err();
142        assert_eq!(
143            err,
144            WaitError::IntervalExceedsTimeout {
145                interval: Duration::from_secs(2),
146                timeout: Duration::from_secs(1),
147            },
148            "a single poll makes the timeout meaningless — say so"
149        );
150        assert!(err.to_string().contains("polled twice"));
151    }
152
153    #[test]
154    fn match_needs_every_region_and_change_needs_one() {
155        let all_high = [0.99, 0.95];
156        let one_low = [0.99, 0.10];
157
158        assert!(satisfied(Condition::Match, &all_high, 0.9));
159        assert!(!satisfied(Condition::Match, &one_low, 0.9), "match is all");
160
161        assert!(satisfied(Condition::Change, &one_low, 0.9), "change is any");
162        assert!(!satisfied(Condition::Change, &all_high, 0.9));
163    }
164
165    #[test]
166    fn the_floor_is_inclusive_on_both_verbs() {
167        // A score exactly at the floor counts as matching, so the two
168        // conditions stay exact complements for a single region.
169        assert!(satisfied(Condition::Match, &[0.9], 0.9));
170        assert!(!satisfied(Condition::Change, &[0.9], 0.9));
171    }
172
173    #[test]
174    fn nothing_to_watch_satisfies_neither() {
175        assert!(!satisfied(Condition::Match, &[], 0.9));
176        assert!(
177            !satisfied(Condition::Change, &[], 0.9),
178            "a wait that verified nothing has not succeeded"
179        );
180    }
181}