1#![allow(dead_code)]
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum SyncQuality {
11 Poor,
13 Fair,
15 Good,
17 Excellent,
19}
20
21impl SyncQuality {
22 #[allow(clippy::cast_precision_loss)]
24 #[must_use]
25 pub fn score(&self) -> f64 {
26 match self {
27 Self::Poor => 0.1,
28 Self::Fair => 0.4,
29 Self::Good => 0.75,
30 Self::Excellent => 1.0,
31 }
32 }
33
34 #[must_use]
36 pub fn label(&self) -> &'static str {
37 match self {
38 Self::Poor => "poor",
39 Self::Fair => "fair",
40 Self::Good => "good",
41 Self::Excellent => "excellent",
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct SyncResult {
49 pub quality: SyncQuality,
51 pub offset_ms: f64,
53 pub confidence: f64,
55 pub stream_id: String,
57}
58
59impl SyncResult {
60 #[must_use]
62 pub fn new(quality: SyncQuality, offset_ms: f64, confidence: f64, stream_id: &str) -> Self {
63 Self {
64 quality,
65 offset_ms,
66 confidence,
67 stream_id: stream_id.to_owned(),
68 }
69 }
70
71 #[must_use]
73 pub fn is_good_sync(&self) -> bool {
74 self.quality >= SyncQuality::Good
75 }
76
77 #[must_use]
79 pub fn abs_offset_ms(&self) -> f64 {
80 self.offset_ms.abs()
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct SyncScorerConfig {
87 pub excellent_threshold_ms: f64,
89 pub good_threshold_ms: f64,
91 pub fair_threshold_ms: f64,
93 pub min_confidence_good: f64,
95}
96
97impl Default for SyncScorerConfig {
98 fn default() -> Self {
99 Self {
100 excellent_threshold_ms: 0.5,
101 good_threshold_ms: 5.0,
102 fair_threshold_ms: 33.0,
103 min_confidence_good: 0.70,
104 }
105 }
106}
107
108#[derive(Debug)]
110pub struct SyncScorer {
111 config: SyncScorerConfig,
112}
113
114impl SyncScorer {
115 #[must_use]
117 pub fn new(config: SyncScorerConfig) -> Self {
118 Self { config }
119 }
120
121 #[must_use]
123 pub fn default_scorer() -> Self {
124 Self::new(SyncScorerConfig::default())
125 }
126
127 #[must_use]
129 pub fn evaluate(&self, stream_id: &str, offset_ms: f64, confidence: f64) -> SyncResult {
130 let abs_off = offset_ms.abs();
131 let quality = if confidence < self.config.min_confidence_good {
132 SyncQuality::Poor
133 } else if abs_off <= self.config.excellent_threshold_ms {
134 SyncQuality::Excellent
135 } else if abs_off <= self.config.good_threshold_ms {
136 SyncQuality::Good
137 } else if abs_off <= self.config.fair_threshold_ms {
138 SyncQuality::Fair
139 } else {
140 SyncQuality::Poor
141 };
142 SyncResult::new(quality, offset_ms, confidence, stream_id)
143 }
144}
145
146#[derive(Debug, Default)]
148pub struct SyncReport {
149 pub results: Vec<SyncResult>,
151}
152
153impl SyncReport {
154 #[must_use]
156 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn add(&mut self, result: SyncResult) {
162 self.results.push(result);
163 }
164
165 #[must_use]
167 pub fn worst_quality(&self) -> Option<SyncQuality> {
168 self.results.iter().map(|r| r.quality).min()
169 }
170
171 #[allow(clippy::cast_precision_loss)]
173 #[must_use]
174 pub fn avg_offset_ms(&self) -> f64 {
175 if self.results.is_empty() {
176 return 0.0;
177 }
178 let sum: f64 = self.results.iter().map(SyncResult::abs_offset_ms).sum();
179 sum / self.results.len() as f64
180 }
181
182 #[must_use]
184 pub fn good_count(&self) -> usize {
185 self.results.iter().filter(|r| r.is_good_sync()).count()
186 }
187
188 #[must_use]
190 pub fn all_good(&self) -> bool {
191 !self.results.is_empty() && self.results.iter().all(SyncResult::is_good_sync)
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
202 fn test_quality_order() {
203 assert!(SyncQuality::Excellent > SyncQuality::Good);
204 assert!(SyncQuality::Good > SyncQuality::Fair);
205 assert!(SyncQuality::Fair > SyncQuality::Poor);
206 }
207
208 #[test]
209 fn test_quality_score_monotone() {
210 assert!(SyncQuality::Excellent.score() > SyncQuality::Good.score());
211 assert!(SyncQuality::Good.score() > SyncQuality::Fair.score());
212 assert!(SyncQuality::Fair.score() > SyncQuality::Poor.score());
213 }
214
215 #[test]
216 fn test_quality_score_range() {
217 for q in [
218 SyncQuality::Poor,
219 SyncQuality::Fair,
220 SyncQuality::Good,
221 SyncQuality::Excellent,
222 ] {
223 let s = q.score();
224 assert!((0.0..=1.0).contains(&s), "score {s} out of [0,1]");
225 }
226 }
227
228 #[test]
229 fn test_quality_labels_non_empty() {
230 for q in [
231 SyncQuality::Poor,
232 SyncQuality::Fair,
233 SyncQuality::Good,
234 SyncQuality::Excellent,
235 ] {
236 assert!(!q.label().is_empty());
237 }
238 }
239
240 #[test]
243 fn test_sync_result_is_good() {
244 let good = SyncResult::new(SyncQuality::Good, 2.0, 0.9, "cam1");
245 let poor = SyncResult::new(SyncQuality::Poor, 50.0, 0.3, "cam2");
246 assert!(good.is_good_sync());
247 assert!(!poor.is_good_sync());
248 }
249
250 #[test]
251 fn test_sync_result_excellent_is_good() {
252 let r = SyncResult::new(SyncQuality::Excellent, 0.1, 0.99, "cam1");
253 assert!(r.is_good_sync());
254 }
255
256 #[test]
257 fn test_abs_offset_negative() {
258 let r = SyncResult::new(SyncQuality::Good, -8.0, 0.85, "cam3");
259 assert!((r.abs_offset_ms() - 8.0).abs() < f64::EPSILON);
260 }
261
262 #[test]
265 fn test_scorer_excellent() {
266 let scorer = SyncScorer::default_scorer();
267 let r = scorer.evaluate("cam1", 0.3, 0.95);
268 assert_eq!(r.quality, SyncQuality::Excellent);
269 }
270
271 #[test]
272 fn test_scorer_good() {
273 let scorer = SyncScorer::default_scorer();
274 let r = scorer.evaluate("cam1", 3.0, 0.80);
275 assert_eq!(r.quality, SyncQuality::Good);
276 }
277
278 #[test]
279 fn test_scorer_fair() {
280 let scorer = SyncScorer::default_scorer();
281 let r = scorer.evaluate("cam1", 15.0, 0.75);
282 assert_eq!(r.quality, SyncQuality::Fair);
283 }
284
285 #[test]
286 fn test_scorer_poor_large_offset() {
287 let scorer = SyncScorer::default_scorer();
288 let r = scorer.evaluate("cam1", 100.0, 0.90);
289 assert_eq!(r.quality, SyncQuality::Poor);
290 }
291
292 #[test]
293 fn test_scorer_poor_low_confidence() {
294 let scorer = SyncScorer::default_scorer();
295 let r = scorer.evaluate("cam1", 0.1, 0.2);
296 assert_eq!(r.quality, SyncQuality::Poor);
297 }
298
299 #[test]
302 fn test_report_empty() {
303 let report = SyncReport::new();
304 assert!(report.worst_quality().is_none());
305 assert!((report.avg_offset_ms()).abs() < f64::EPSILON);
306 assert!(!report.all_good());
307 }
308
309 #[test]
310 fn test_report_worst_quality() {
311 let scorer = SyncScorer::default_scorer();
312 let mut report = SyncReport::new();
313 report.add(scorer.evaluate("cam1", 0.2, 0.99));
314 report.add(scorer.evaluate("cam2", 20.0, 0.80));
315 assert_eq!(report.worst_quality(), Some(SyncQuality::Fair));
316 }
317
318 #[test]
319 fn test_report_avg_offset() {
320 let mut report = SyncReport::new();
321 report.add(SyncResult::new(SyncQuality::Good, 4.0, 0.9, "a"));
322 report.add(SyncResult::new(SyncQuality::Good, 6.0, 0.9, "b"));
323 assert!((report.avg_offset_ms() - 5.0).abs() < 1e-10);
324 }
325
326 #[test]
327 fn test_report_all_good() {
328 let scorer = SyncScorer::default_scorer();
329 let mut report = SyncReport::new();
330 report.add(scorer.evaluate("cam1", 0.3, 0.95));
331 report.add(scorer.evaluate("cam2", 2.0, 0.80));
332 assert!(report.all_good());
333 }
334
335 #[test]
336 fn test_report_good_count() {
337 let scorer = SyncScorer::default_scorer();
338 let mut report = SyncReport::new();
339 report.add(scorer.evaluate("cam1", 0.3, 0.95)); report.add(scorer.evaluate("cam2", 2.0, 0.80)); report.add(scorer.evaluate("cam3", 50.0, 0.8)); assert_eq!(report.good_count(), 2);
343 }
344}