Skip to main content

jugar_probar/animation/
timing.rs

1//! Animation timing verification.
2//!
3//! Compares declared animation events against actual timing data
4//! (from render reports, metadata, or frame analysis).
5
6use super::types::{
7    AnimationEvent, AnimationReport, AnimationTimeline, AnimationVerdict, EventResult,
8};
9
10/// Actual observed animation timing from render analysis.
11#[derive(Clone, Debug)]
12pub struct ObservedEvent {
13    /// Event name (must match declaration)
14    pub name: String,
15    /// Observed time in seconds
16    pub time_secs: f64,
17}
18
19/// Verify animation timeline against observed events.
20///
21/// For each declared event, finds the matching observed event by name
22/// and computes the timing delta.
23#[must_use]
24pub fn verify_timeline(
25    timeline: &AnimationTimeline,
26    observed: &[ObservedEvent],
27    tolerance_ms: f64,
28) -> AnimationReport {
29    if !timeline.has_events() {
30        return AnimationReport {
31            video_id: timeline.video_id.clone(),
32            verdict: AnimationVerdict::NoEvents,
33            events: Vec::new(),
34            total_events: 0,
35            verified_events: 0,
36            max_delta_ms: 0.0,
37            mean_delta_ms: 0.0,
38        };
39    }
40
41    let mut results = Vec::new();
42    let mut verified = 0;
43    let mut max_delta: f64 = 0.0;
44    let mut delta_sum: f64 = 0.0;
45    let mut delta_count: usize = 0;
46
47    for event in &timeline.events {
48        let observed_event = find_observed_by_name(observed, &event.name);
49        let result = match observed_event {
50            Some(obs) => {
51                let delta_ms = (obs.time_secs - event.expected_secs) * 1000.0;
52                let abs_delta = delta_ms.abs();
53                let passed = abs_delta <= tolerance_ms;
54                if passed {
55                    verified += 1;
56                }
57                if abs_delta > max_delta {
58                    max_delta = abs_delta;
59                }
60                delta_sum += abs_delta;
61                delta_count += 1;
62                EventResult {
63                    name: event.name.clone(),
64                    event_type: event.event_type.clone(),
65                    expected_secs: event.expected_secs,
66                    actual_secs: Some(obs.time_secs),
67                    delta_ms: Some(delta_ms),
68                    passed,
69                }
70            }
71            None => EventResult {
72                name: event.name.clone(),
73                event_type: event.event_type.clone(),
74                expected_secs: event.expected_secs,
75                actual_secs: None,
76                delta_ms: None,
77                passed: false,
78            },
79        };
80        results.push(result);
81    }
82
83    let mean_delta = if delta_count > 0 {
84        delta_sum / delta_count as f64
85    } else {
86        0.0
87    };
88
89    let verdict = if verified == timeline.events.len() {
90        AnimationVerdict::Pass
91    } else {
92        AnimationVerdict::Fail
93    };
94
95    AnimationReport {
96        video_id: timeline.video_id.clone(),
97        verdict,
98        events: results,
99        total_events: timeline.events.len(),
100        verified_events: verified,
101        max_delta_ms: max_delta,
102        mean_delta_ms: mean_delta,
103    }
104}
105
106/// Verify animation events against expected timing from a flat list.
107///
108/// Simplified API when you have expected events without a full timeline.
109#[must_use]
110pub fn verify_events(
111    expected: &[AnimationEvent],
112    observed: &[ObservedEvent],
113    tolerance_ms: f64,
114    video_id: &str,
115) -> AnimationReport {
116    let timeline = AnimationTimeline {
117        video_id: video_id.to_string(),
118        events: expected.to_vec(),
119    };
120    verify_timeline(&timeline, observed, tolerance_ms)
121}
122
123fn find_observed_by_name<'a>(
124    observed: &'a [ObservedEvent],
125    name: &str,
126) -> Option<&'a ObservedEvent> {
127    observed.iter().find(|o| o.name == name)
128}
129
130#[cfg(test)]
131#[allow(clippy::unwrap_used)]
132mod tests {
133    use super::*;
134    use crate::animation::types::AnimationEventType;
135
136    fn make_event(name: &str, secs: f64) -> AnimationEvent {
137        AnimationEvent {
138            name: name.to_string(),
139            event_type: AnimationEventType::PhysicsEvent,
140            expected_secs: secs,
141            duration_secs: None,
142            easing: None,
143        }
144    }
145
146    fn make_observed(name: &str, secs: f64) -> ObservedEvent {
147        ObservedEvent {
148            name: name.to_string(),
149            time_secs: secs,
150        }
151    }
152
153    #[test]
154    fn test_perfect_match() {
155        let timeline = AnimationTimeline {
156            video_id: "test".to_string(),
157            events: vec![make_event("land_0", 1.7), make_event("land_1", 2.4)],
158        };
159        let observed = vec![make_observed("land_0", 1.7), make_observed("land_1", 2.4)];
160        let report = verify_timeline(&timeline, &observed, 20.0);
161        assert_eq!(report.verdict, AnimationVerdict::Pass);
162        assert_eq!(report.verified_events, 2);
163    }
164
165    #[test]
166    fn test_within_tolerance() {
167        let timeline = AnimationTimeline {
168            video_id: "test".to_string(),
169            events: vec![make_event("land_0", 1.7)],
170        };
171        let observed = vec![make_observed("land_0", 1.71)]; // 10ms delta
172        let report = verify_timeline(&timeline, &observed, 20.0);
173        assert_eq!(report.verdict, AnimationVerdict::Pass);
174    }
175
176    #[test]
177    fn test_exceeds_tolerance() {
178        let timeline = AnimationTimeline {
179            video_id: "test".to_string(),
180            events: vec![make_event("land_0", 1.7)],
181        };
182        let observed = vec![make_observed("land_0", 1.408)]; // -292ms
183        let report = verify_timeline(&timeline, &observed, 20.0);
184        assert_eq!(report.verdict, AnimationVerdict::Fail);
185        assert!((report.max_delta_ms - 292.0).abs() < 0.1);
186    }
187
188    #[test]
189    fn test_missing_observed_event() {
190        let timeline = AnimationTimeline {
191            video_id: "test".to_string(),
192            events: vec![make_event("land_0", 1.7)],
193        };
194        let observed: Vec<ObservedEvent> = vec![];
195        let report = verify_timeline(&timeline, &observed, 20.0);
196        assert_eq!(report.verdict, AnimationVerdict::Fail);
197        assert!(report.events[0].actual_secs.is_none());
198    }
199
200    #[test]
201    fn test_no_events() {
202        let timeline = AnimationTimeline {
203            video_id: "empty".to_string(),
204            events: vec![],
205        };
206        let observed: Vec<ObservedEvent> = vec![];
207        let report = verify_timeline(&timeline, &observed, 20.0);
208        assert_eq!(report.verdict, AnimationVerdict::NoEvents);
209    }
210
211    #[test]
212    fn test_partial_match() {
213        let timeline = AnimationTimeline {
214            video_id: "test".to_string(),
215            events: vec![make_event("land_0", 1.7), make_event("land_1", 2.4)],
216        };
217        let observed = vec![make_observed("land_0", 1.7)]; // land_1 missing
218        let report = verify_timeline(&timeline, &observed, 20.0);
219        assert_eq!(report.verdict, AnimationVerdict::Fail);
220        assert_eq!(report.verified_events, 1);
221        assert_eq!(report.total_events, 2);
222    }
223
224    #[test]
225    fn test_mean_delta() {
226        let timeline = AnimationTimeline {
227            video_id: "test".to_string(),
228            events: vec![make_event("a", 1.0), make_event("b", 2.0)],
229        };
230        // 10ms and 20ms deltas -> mean = 15ms
231        let observed = vec![make_observed("a", 1.01), make_observed("b", 2.02)];
232        let report = verify_timeline(&timeline, &observed, 25.0);
233        assert_eq!(report.verdict, AnimationVerdict::Pass);
234        assert!((report.mean_delta_ms - 15.0).abs() < 0.1);
235    }
236
237    #[test]
238    fn test_verify_events_api() {
239        let events = vec![make_event("land", 1.5)];
240        let observed = vec![make_observed("land", 1.5)];
241        let report = verify_events(&events, &observed, 20.0, "test");
242        assert_eq!(report.verdict, AnimationVerdict::Pass);
243        assert_eq!(report.video_id, "test");
244    }
245
246    #[test]
247    fn test_event_result_details() {
248        let timeline = AnimationTimeline {
249            video_id: "test".to_string(),
250            events: vec![make_event("bounce", 3.0)],
251        };
252        let observed = vec![make_observed("bounce", 3.015)];
253        let report = verify_timeline(&timeline, &observed, 20.0);
254        let ev = &report.events[0];
255        assert_eq!(ev.name, "bounce");
256        assert!((ev.actual_secs.unwrap() - 3.015).abs() < f64::EPSILON);
257        assert!((ev.delta_ms.unwrap() - 15.0).abs() < 0.1);
258        assert!(ev.passed);
259    }
260
261    #[test]
262    fn test_negative_delta() {
263        let timeline = AnimationTimeline {
264            video_id: "test".to_string(),
265            events: vec![make_event("land", 1.7)],
266        };
267        let observed = vec![make_observed("land", 1.69)]; // 10ms early
268        let report = verify_timeline(&timeline, &observed, 20.0);
269        assert_eq!(report.verdict, AnimationVerdict::Pass);
270        let delta = report.events[0].delta_ms.unwrap();
271        assert!(delta < 0.0); // negative means early
272    }
273}