Skip to main content

jugar_probar/animation/
types.rs

1//! Types for animation verification.
2//!
3//! Provides structures for declaring expected animation events and
4//! verifying their timing in rendered output.
5
6use serde::{Deserialize, Serialize};
7
8/// Animation timeline declaration — the expected events in a render.
9///
10/// This is the contract between the renderer (rmedia) and the verifier (probar).
11/// The renderer writes a timeline JSON alongside the video; the verifier checks it.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct AnimationTimeline {
14    /// Video identifier
15    pub video_id: String,
16    /// Expected animation events
17    pub events: Vec<AnimationEvent>,
18}
19
20impl AnimationTimeline {
21    /// Total number of events.
22    #[must_use]
23    pub fn event_count(&self) -> usize {
24        self.events.len()
25    }
26
27    /// Check if timeline has any events.
28    #[must_use]
29    pub fn has_events(&self) -> bool {
30        !self.events.is_empty()
31    }
32}
33
34/// A declared animation event with expected timing.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct AnimationEvent {
37    /// Event name/label (e.g., "bullet_0_land", "logo_bounce_start")
38    pub name: String,
39    /// Event type
40    pub event_type: AnimationEventType,
41    /// Expected time in seconds
42    pub expected_secs: f64,
43    /// Expected duration in seconds (for events with duration)
44    pub duration_secs: Option<f64>,
45    /// Easing function name (for transitions)
46    pub easing: Option<String>,
47}
48
49/// Types of animation events.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub enum AnimationEventType {
52    /// Element appears/enters
53    Enter,
54    /// Element exits/disappears
55    Exit,
56    /// Transition starts (fade, slide, etc.)
57    TransitionStart,
58    /// Transition ends
59    TransitionEnd,
60    /// Keyframe hit (specific animation point)
61    Keyframe,
62    /// Physics event (bounce, land, etc.)
63    PhysicsEvent,
64}
65
66impl std::fmt::Display for AnimationEventType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Enter => write!(f, "enter"),
70            Self::Exit => write!(f, "exit"),
71            Self::TransitionStart => write!(f, "transition_start"),
72            Self::TransitionEnd => write!(f, "transition_end"),
73            Self::Keyframe => write!(f, "keyframe"),
74            Self::PhysicsEvent => write!(f, "physics_event"),
75        }
76    }
77}
78
79/// Animation verification report.
80#[derive(Clone, Debug, Serialize)]
81pub struct AnimationReport {
82    /// Video identifier
83    pub video_id: String,
84    /// Overall verdict
85    pub verdict: AnimationVerdict,
86    /// Per-event results
87    pub events: Vec<EventResult>,
88    /// Total events declared
89    pub total_events: usize,
90    /// Events verified within tolerance
91    pub verified_events: usize,
92    /// Maximum timing delta in milliseconds
93    pub max_delta_ms: f64,
94    /// Mean timing delta in milliseconds
95    pub mean_delta_ms: f64,
96}
97
98/// Per-event verification result.
99#[derive(Clone, Debug, Serialize)]
100pub struct EventResult {
101    /// Event name
102    pub name: String,
103    /// Event type
104    pub event_type: AnimationEventType,
105    /// Expected time in seconds
106    pub expected_secs: f64,
107    /// Actual time in seconds (None if not detected)
108    pub actual_secs: Option<f64>,
109    /// Delta in milliseconds
110    pub delta_ms: Option<f64>,
111    /// Whether the event passed timing check
112    pub passed: bool,
113}
114
115/// Overall animation verification verdict.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub enum AnimationVerdict {
118    /// All events verified within tolerance
119    Pass,
120    /// One or more events failed
121    Fail,
122    /// No events to verify
123    NoEvents,
124}
125
126impl std::fmt::Display for AnimationVerdict {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::Pass => write!(f, "PASS"),
130            Self::Fail => write!(f, "FAIL"),
131            Self::NoEvents => write!(f, "NO EVENTS"),
132        }
133    }
134}
135
136/// Easing function definitions for animation curves.
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub enum EasingFunction {
139    /// Linear interpolation
140    Linear,
141    /// Quadratic ease-in
142    EaseIn,
143    /// Quadratic ease-out
144    EaseOut,
145    /// Quadratic ease-in-out
146    EaseInOut,
147    /// Cubic ease-in
148    CubicIn,
149    /// Cubic ease-out
150    CubicOut,
151    /// Cubic ease-in-out
152    CubicInOut,
153    /// Bounce effect
154    Bounce,
155    /// Custom cubic bezier
156    CubicBezier(f64, f64, f64, f64),
157}
158
159impl EasingFunction {
160    /// Evaluate the easing function at time t (0.0-1.0).
161    ///
162    /// Returns the interpolated value (0.0-1.0).
163    #[must_use]
164    pub fn evaluate(&self, t: f64) -> f64 {
165        let t = t.clamp(0.0, 1.0);
166        match self {
167            Self::Linear => t,
168            Self::EaseIn => t * t,
169            Self::EaseOut => t * (2.0 - t),
170            Self::EaseInOut => {
171                if t < 0.5 {
172                    2.0 * t * t
173                } else {
174                    -1.0 + (4.0 - 2.0 * t) * t
175                }
176            }
177            Self::CubicIn => t * t * t,
178            Self::CubicOut => {
179                let t1 = t - 1.0;
180                t1 * t1 * t1 + 1.0
181            }
182            Self::CubicInOut => {
183                if t < 0.5 {
184                    4.0 * t * t * t
185                } else {
186                    let t1 = 2.0 * t - 2.0;
187                    0.5 * t1 * t1 * t1 + 1.0
188                }
189            }
190            Self::Bounce => bounce_ease_out(t),
191            Self::CubicBezier(x1, y1, x2, y2) => cubic_bezier_approx(t, *x1, *y1, *x2, *y2),
192        }
193    }
194}
195
196/// Bounce easing function.
197fn bounce_ease_out(t: f64) -> f64 {
198    if t < 1.0 / 2.75 {
199        7.5625 * t * t
200    } else if t < 2.0 / 2.75 {
201        let t = t - 1.5 / 2.75;
202        7.5625 * t * t + 0.75
203    } else if t < 2.5 / 2.75 {
204        let t = t - 2.25 / 2.75;
205        7.5625 * t * t + 0.9375
206    } else {
207        let t = t - 2.625 / 2.75;
208        7.5625 * t * t + 0.984_375
209    }
210}
211
212/// Approximate cubic bezier evaluation (for CSS-style timing functions).
213fn cubic_bezier_approx(t: f64, _x1: f64, y1: f64, _x2: f64, y2: f64) -> f64 {
214    // Simple approximation using De Casteljau subdivision
215    // Control points: (0,0), (x1,y1), (x2,y2), (1,1)
216    let mt = 1.0 - t;
217    let mt2 = mt * mt;
218    let t2 = t * t;
219    mt2 * mt * 0.0 + 3.0 * mt2 * t * y1 + 3.0 * mt * t2 * y2 + t2 * t * 1.0
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_animation_verdict_display() {
229        assert_eq!(AnimationVerdict::Pass.to_string(), "PASS");
230        assert_eq!(AnimationVerdict::Fail.to_string(), "FAIL");
231        assert_eq!(AnimationVerdict::NoEvents.to_string(), "NO EVENTS");
232    }
233
234    #[test]
235    fn test_animation_event_type_display() {
236        assert_eq!(AnimationEventType::Enter.to_string(), "enter");
237        assert_eq!(AnimationEventType::PhysicsEvent.to_string(), "physics_event");
238    }
239
240    #[test]
241    fn test_timeline_event_count() {
242        let timeline = AnimationTimeline {
243            video_id: "test".to_string(),
244            events: vec![
245                AnimationEvent {
246                    name: "event1".to_string(),
247                    event_type: AnimationEventType::Enter,
248                    expected_secs: 1.0,
249                    duration_secs: None,
250                    easing: None,
251                },
252                AnimationEvent {
253                    name: "event2".to_string(),
254                    event_type: AnimationEventType::Exit,
255                    expected_secs: 2.0,
256                    duration_secs: None,
257                    easing: None,
258                },
259            ],
260        };
261        assert_eq!(timeline.event_count(), 2);
262        assert!(timeline.has_events());
263    }
264
265    #[test]
266    fn test_timeline_empty() {
267        let timeline = AnimationTimeline {
268            video_id: "empty".to_string(),
269            events: vec![],
270        };
271        assert_eq!(timeline.event_count(), 0);
272        assert!(!timeline.has_events());
273    }
274
275    #[test]
276    fn test_timeline_json_roundtrip() {
277        let timeline = AnimationTimeline {
278            video_id: "test".to_string(),
279            events: vec![AnimationEvent {
280                name: "bullet_land".to_string(),
281                event_type: AnimationEventType::PhysicsEvent,
282                expected_secs: 1.7,
283                duration_secs: Some(0.05),
284                easing: Some("bounce".to_string()),
285            }],
286        };
287        let json = serde_json::to_string(&timeline).unwrap();
288        let parsed: AnimationTimeline = serde_json::from_str(&json).unwrap();
289        assert_eq!(parsed.video_id, "test");
290        assert_eq!(parsed.events.len(), 1);
291        assert_eq!(parsed.events[0].event_type, AnimationEventType::PhysicsEvent);
292    }
293
294    #[test]
295    fn test_easing_linear() {
296        let f = EasingFunction::Linear;
297        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
298        assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
299        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
300    }
301
302    #[test]
303    fn test_easing_ease_in() {
304        let f = EasingFunction::EaseIn;
305        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
306        assert!((f.evaluate(0.5) - 0.25).abs() < f64::EPSILON); // t^2
307        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
308    }
309
310    #[test]
311    fn test_easing_ease_out() {
312        let f = EasingFunction::EaseOut;
313        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
314        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
315        // ease-out should be faster at start
316        assert!(f.evaluate(0.5) > 0.5);
317    }
318
319    #[test]
320    fn test_easing_ease_in_out() {
321        let f = EasingFunction::EaseInOut;
322        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
323        assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
324        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
325    }
326
327    #[test]
328    fn test_easing_cubic_in() {
329        let f = EasingFunction::CubicIn;
330        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
331        assert!((f.evaluate(0.5) - 0.125).abs() < f64::EPSILON); // t^3
332        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
333    }
334
335    #[test]
336    fn test_easing_cubic_out() {
337        let f = EasingFunction::CubicOut;
338        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
339        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
340    }
341
342    #[test]
343    fn test_easing_cubic_in_out() {
344        let f = EasingFunction::CubicInOut;
345        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
346        assert!((f.evaluate(0.5) - 0.5).abs() < f64::EPSILON);
347        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
348    }
349
350    #[test]
351    fn test_easing_bounce() {
352        let f = EasingFunction::Bounce;
353        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
354        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
355        // Bounce should have oscillations
356        assert!(f.evaluate(0.5) > 0.0);
357    }
358
359    #[test]
360    fn test_easing_cubic_bezier() {
361        let f = EasingFunction::CubicBezier(0.25, 0.1, 0.25, 1.0); // CSS "ease"
362        assert!((f.evaluate(0.0)).abs() < f64::EPSILON);
363        assert!((f.evaluate(1.0) - 1.0).abs() < f64::EPSILON);
364    }
365
366    #[test]
367    fn test_easing_clamp() {
368        let f = EasingFunction::Linear;
369        assert!((f.evaluate(-0.5)).abs() < f64::EPSILON);
370        assert!((f.evaluate(1.5) - 1.0).abs() < f64::EPSILON);
371    }
372
373    #[test]
374    fn test_event_result() {
375        let result = EventResult {
376            name: "bullet_land".to_string(),
377            event_type: AnimationEventType::PhysicsEvent,
378            expected_secs: 1.7,
379            actual_secs: Some(1.71),
380            delta_ms: Some(10.0),
381            passed: true,
382        };
383        assert!(result.passed);
384    }
385
386    #[test]
387    fn test_animation_report_serialization() {
388        let report = AnimationReport {
389            video_id: "test".to_string(),
390            verdict: AnimationVerdict::Pass,
391            events: vec![],
392            total_events: 0,
393            verified_events: 0,
394            max_delta_ms: 0.0,
395            mean_delta_ms: 0.0,
396        };
397        let json = serde_json::to_string(&report).unwrap();
398        assert!(json.contains("\"verdict\":\"Pass\""));
399    }
400}