1use std::time::Duration;
18
19use serde::Serialize;
20use thiserror::Error;
21
22#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct RegionWatch {
25 pub index: usize,
27 pub label: String,
28 pub monitor: usize,
29 pub score: f64,
31 pub matching: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Condition {
39 Match,
41 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
60pub 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 Ok(u32::try_from(spans).unwrap_or(u32::MAX).saturating_add(1))
77}
78
79#[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 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 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 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}