Skip to main content

proofsheet_core/
progress.rs

1//! Progress reporting.
2//!
3//! A capture run over a full store matrix launches a browser per device and
4//! takes real seconds each. Silence for a minute is indistinguishable from a
5//! hang, so the core emits events and the caller decides how to render them.
6//!
7//! The core deliberately does **not** print. A library that writes to stdout
8//! is unusable from a binding, and this crate is about to be consumed from
9//! Node and Python where the host owns the console.
10
11use std::fmt;
12use std::time::Duration;
13
14use crate::capture::Capture;
15use crate::device::Device;
16
17/// What happened to one device.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Outcome {
20    /// Captured at exactly the required size.
21    Exact,
22    /// Captured, but the pixels do not match the requirement.
23    OffSize,
24    /// Did not capture at all.
25    Failed,
26}
27
28impl Outcome {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Outcome::Exact => "exact",
32            Outcome::OffSize => "off-size",
33            Outcome::Failed => "failed",
34        }
35    }
36
37    /// Whether this outcome should make the overall run fail.
38    pub fn is_bad(self) -> bool {
39        !matches!(self, Outcome::Exact)
40    }
41}
42
43impl fmt::Display for Outcome {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49/// A tally of a finished run.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51pub struct Summary {
52    pub exact: usize,
53    pub off_size: usize,
54    pub failed: usize,
55}
56
57impl Summary {
58    pub fn total(&self) -> usize {
59        self.exact + self.off_size + self.failed
60    }
61
62    /// A run is successful only if something happened and nothing went wrong.
63    ///
64    /// The `total() > 0` clause is deliberate: a run that captured nothing
65    /// has an empty problem list, and without this it would report success.
66    /// An empty green is the most misleading result a tool can produce.
67    pub fn ok(&self) -> bool {
68        self.total() > 0 && self.off_size == 0 && self.failed == 0
69    }
70
71    fn record(&mut self, o: Outcome) {
72        match o {
73            Outcome::Exact => self.exact += 1,
74            Outcome::OffSize => self.off_size += 1,
75            Outcome::Failed => self.failed += 1,
76        }
77    }
78}
79
80/// Everything known about one finished device.
81///
82/// Passed as a struct rather than eight positional parameters, so adding a
83/// field later does not break every implementor — and so nobody transposes
84/// two same-typed arguments at a call site.
85#[derive(Debug, Clone, Copy)]
86pub struct DeviceEvent<'a> {
87    /// 1-based position in the run.
88    pub index: usize,
89    pub total: usize,
90    pub device: &'a Device,
91    pub outcome: Outcome,
92    /// Present unless the capture failed outright.
93    pub capture: Option<&'a Capture>,
94    pub elapsed: Duration,
95    /// Present only on failure.
96    pub error: Option<&'a str>,
97}
98
99/// Events emitted as a run proceeds.
100///
101/// Every method has a default no-op body so an implementor can observe only
102/// what it cares about.
103pub trait Progress {
104    /// Called once, before any device is touched.
105    fn run_started(&mut self, _total: usize, _url: &str) {}
106
107    /// Called before each device, with a 1-based index.
108    fn device_started(&mut self, _index: usize, _total: usize, _device: &Device) {}
109
110    /// Called after each device, successful or not.
111    fn device_finished(&mut self, _event: &DeviceEvent<'_>) {}
112
113    /// Called once, after every device.
114    fn run_finished(&mut self, _summary: Summary, _elapsed: Duration) {}
115}
116
117/// Discards everything. Useful for tests and for callers that want silence.
118#[derive(Debug, Default, Clone, Copy)]
119pub struct Silent;
120
121impl Progress for Silent {}
122
123/// Collects outcomes in memory. Used by the bindings, which return a result
124/// object rather than streaming to a console.
125#[derive(Debug, Default, Clone)]
126pub struct Collector {
127    pub summary: Summary,
128    pub events: Vec<(String, Outcome, u128)>,
129}
130
131impl Progress for Collector {
132    fn device_finished(&mut self, e: &DeviceEvent<'_>) {
133        self.summary.record(e.outcome);
134        self.events
135            .push((e.device.id.clone(), e.outcome, e.elapsed.as_millis()));
136    }
137}
138
139/// Tally outcomes without holding onto them.
140#[derive(Debug, Default, Clone, Copy)]
141pub struct Tally(pub Summary);
142
143impl Progress for Tally {
144    fn device_finished(&mut self, e: &DeviceEvent<'_>) {
145        self.0.record(e.outcome);
146    }
147}
148
149/// Render a `current/total` bar of the given width.
150///
151/// Pure and separately testable, because off-by-one bars are the classic
152/// place a progress indicator lies about how far along it is.
153pub fn bar(current: usize, total: usize, width: usize) -> String {
154    if total == 0 || width == 0 {
155        return String::new();
156    }
157    let filled = (current.min(total) * width) / total;
158    let mut s = String::with_capacity(width);
159    for i in 0..width {
160        s.push(if i < filled { '#' } else { '.' });
161    }
162    s
163}
164
165/// Format a duration the way a person reads it.
166pub fn human(d: Duration) -> String {
167    let ms = d.as_millis();
168    if ms < 1000 {
169        format!("{ms}ms")
170    } else if ms < 60_000 {
171        format!("{:.1}s", d.as_secs_f64())
172    } else {
173        format!("{}m{:02}s", d.as_secs() / 60, d.as_secs() % 60)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn empty_run_is_not_a_success() {
183        // The whole point: no captures must never read as "all good".
184        assert!(!Summary::default().ok());
185    }
186
187    #[test]
188    fn clean_run_is_a_success() {
189        let s = Summary {
190            exact: 3,
191            ..Default::default()
192        };
193        assert!(s.ok());
194        assert_eq!(s.total(), 3);
195    }
196
197    #[test]
198    fn any_problem_fails_the_run() {
199        assert!(!Summary {
200            exact: 5,
201            off_size: 1,
202            failed: 0
203        }
204        .ok());
205        assert!(!Summary {
206            exact: 5,
207            off_size: 0,
208            failed: 1
209        }
210        .ok());
211    }
212
213    #[test]
214    fn bar_endpoints_are_exact() {
215        assert_eq!(bar(0, 10, 10), "..........");
216        assert_eq!(bar(10, 10, 10), "##########");
217        assert_eq!(bar(5, 10, 10), "#####.....");
218    }
219
220    #[test]
221    fn bar_never_overflows_its_width() {
222        for cur in 0..30 {
223            assert_eq!(bar(cur, 10, 8).chars().count(), 8);
224        }
225        // Overshoot must clamp, not panic or run long.
226        assert_eq!(bar(99, 10, 8), "########");
227    }
228
229    #[test]
230    fn bar_handles_degenerate_input() {
231        assert_eq!(bar(1, 0, 10), "");
232        assert_eq!(bar(1, 10, 0), "");
233    }
234
235    #[test]
236    fn durations_read_naturally() {
237        assert_eq!(human(Duration::from_millis(250)), "250ms");
238        assert_eq!(human(Duration::from_millis(1500)), "1.5s");
239        assert_eq!(human(Duration::from_secs(125)), "2m05s");
240    }
241
242    #[test]
243    fn outcome_badness() {
244        assert!(!Outcome::Exact.is_bad());
245        assert!(Outcome::OffSize.is_bad());
246        assert!(Outcome::Failed.is_bad());
247    }
248
249    #[test]
250    fn collector_records_every_device() {
251        let d = crate::device::builtin().into_iter().next().unwrap();
252        let mut c = Collector::default();
253        for outcome in [Outcome::Exact, Outcome::Failed] {
254            c.device_finished(&DeviceEvent {
255                index: 1,
256                total: 2,
257                device: &d,
258                outcome,
259                capture: None,
260                elapsed: Duration::from_millis(5),
261                error: None,
262            });
263        }
264        assert_eq!(c.summary.total(), 2);
265        assert_eq!(c.summary.failed, 1);
266        assert_eq!(c.events.len(), 2);
267        assert!(!c.summary.ok());
268    }
269}