dotmax/animation/
prerender.rs

1//! Pre-rendered animation storage and playback.
2//!
3//! This module provides [`PrerenderedAnimation`], a struct for pre-computing animation
4//! frames and playing them back with zero computation overhead. This is ideal for:
5//!
6//! - **Loading spinners**: Known, looping patterns that repeat indefinitely
7//! - **Intro animations**: Fixed sequences shown at startup
8//! - **Caching complex computations**: Fractal zooms, particle effects, etc.
9//!
10//! # Overview
11//!
12//! `PrerenderedAnimation` stores a sequence of [`BrailleGrid`](crate::BrailleGrid) frames
13//! that can be played back at a specified frame rate. Unlike [`AnimationLoop`](super::AnimationLoop),
14//! which computes frames on-the-fly, pre-rendered animations front-load all computation,
15//! enabling buttery-smooth playback even for complex graphics.
16//!
17//! # Memory Usage
18//!
19//! Each frame uses approximately `width × height` bytes. For a typical 80×24 terminal,
20//! that's about 2KB per frame. A 10-second animation at 30fps (300 frames) uses
21//! approximately 600KB.
22//!
23//! # File Format
24//!
25//! Animations can be saved to and loaded from disk using a simple binary format:
26//!
27//! | Offset | Size   | Field       | Description                              |
28//! |--------|--------|-------------|------------------------------------------|
29//! | 0      | 4      | Magic       | `b"DMAX"` - File type identifier        |
30//! | 4      | 1      | Version     | Format version (currently 1)            |
31//! | 5      | 4      | Frame Rate  | Target FPS (u32 little-endian)          |
32//! | 9      | 4      | Frame Count | Number of frames (u32 little-endian)    |
33//! | 13     | 4      | Width       | Grid width in cells (u32 little-endian) |
34//! | 17     | 4      | Height      | Grid height in cells (u32 little-endian)|
35//! | 21     | N      | Frame Data  | Sequential frame bytes (width*height per frame) |
36//!
37//! # Example
38//!
39//! ```no_run
40//! use dotmax::animation::PrerenderedAnimation;
41//! use dotmax::BrailleGrid;
42//! use dotmax::TerminalRenderer;
43//!
44//! // Pre-render frames (expensive computation done once)
45//! let mut animation = PrerenderedAnimation::new(30);
46//! for frame_num in 0..60 {
47//!     let mut grid = BrailleGrid::new(80, 24).unwrap();
48//!     // Draw frame content (e.g., rotating shape)
49//!     let angle = (frame_num as f64) * 6.0 * std::f64::consts::PI / 180.0;
50//!     let cx = 80;
51//!     let cy = 48;
52//!     for r in 0..30 {
53//!         let x = cx + (angle.cos() * r as f64) as i32;
54//!         let y = cy + (angle.sin() * r as f64) as i32;
55//!         if x >= 0 && y >= 0 {
56//!             let _ = grid.set_dot(x as usize, y as usize);
57//!         }
58//!     }
59//!     animation.add_frame(grid);
60//! }
61//!
62//! // Playback is instant - no computation
63//! // let mut renderer = TerminalRenderer::new().unwrap();
64//! // animation.play(&mut renderer).unwrap();
65//! ```
66
67use crate::animation::FrameTimer;
68use crate::error::DotmaxError;
69use crate::grid::BrailleGrid;
70use crate::render::TerminalRenderer;
71use crossterm::event::{self, Event, KeyCode, KeyModifiers};
72use std::fs::File;
73use std::io::{BufReader, BufWriter, Read, Write};
74use std::path::Path;
75use std::time::Duration;
76use tracing::debug;
77
78/// Magic bytes for the DMAX animation file format.
79const MAGIC: &[u8; 4] = b"DMAX";
80
81/// Current version of the file format.
82const VERSION: u8 = 1;
83
84/// Minimum allowed target FPS.
85const MIN_FPS: u32 = 1;
86
87/// Maximum allowed target FPS.
88const MAX_FPS: u32 = 240;
89
90/// Pre-rendered animation for optimal playback performance.
91///
92/// `PrerenderedAnimation` stores a sequence of [`BrailleGrid`] frames that can be
93/// played back at a specified frame rate with zero computation during playback.
94/// This is ideal for loading spinners, intro animations, and caching expensive
95/// computations.
96///
97/// # Memory Usage
98///
99/// Each frame uses approximately `width × height` bytes. For a typical 80×24
100/// terminal, that's about 2KB per frame. A 10-second animation at 30fps (300
101/// frames) uses approximately 600KB.
102///
103/// # Example
104///
105/// ```
106/// use dotmax::animation::PrerenderedAnimation;
107/// use dotmax::BrailleGrid;
108///
109/// // Create animation with target frame rate
110/// let mut animation = PrerenderedAnimation::new(30);
111///
112/// // Add pre-computed frames
113/// let grid = BrailleGrid::new(10, 5).unwrap();
114/// animation.add_frame(grid);
115///
116/// assert_eq!(animation.frame_count(), 1);
117/// assert_eq!(animation.frame_rate(), 30);
118/// ```
119#[derive(Debug)]
120pub struct PrerenderedAnimation {
121    /// Pre-rendered frames stored in sequence.
122    frames: Vec<BrailleGrid>,
123    /// Target frames per second (1-240).
124    frame_rate: u32,
125}
126
127impl PrerenderedAnimation {
128    /// Creates a new empty pre-rendered animation with the specified frame rate.
129    ///
130    /// The frame rate is clamped to the valid range (1-240). Values outside
131    /// this range are silently corrected to the nearest valid value.
132    ///
133    /// # Arguments
134    ///
135    /// * `frame_rate` - Target frames per second (1-240, clamped if out of range)
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use dotmax::animation::PrerenderedAnimation;
141    ///
142    /// // Standard 30 FPS animation
143    /// let animation = PrerenderedAnimation::new(30);
144    /// assert_eq!(animation.frame_rate(), 30);
145    /// assert_eq!(animation.frame_count(), 0);
146    ///
147    /// // Values are clamped to valid range
148    /// let animation = PrerenderedAnimation::new(0);
149    /// assert_eq!(animation.frame_rate(), 1);  // Clamped to minimum
150    ///
151    /// let animation = PrerenderedAnimation::new(500);
152    /// assert_eq!(animation.frame_rate(), 240);  // Clamped to maximum
153    /// ```
154    #[must_use]
155    pub fn new(frame_rate: u32) -> Self {
156        Self {
157            frames: Vec::new(),
158            frame_rate: frame_rate.clamp(MIN_FPS, MAX_FPS),
159        }
160    }
161
162    /// Adds a frame to the animation.
163    ///
164    /// Frames are stored by value (owned `BrailleGrid`). There is no validation
165    /// on frame dimensions - mixed sizes are allowed for flexibility.
166    ///
167    /// # Arguments
168    ///
169    /// * `frame` - The [`BrailleGrid`] to add to the animation
170    ///
171    /// # Returns
172    ///
173    /// `&mut Self` for builder-style method chaining.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use dotmax::animation::PrerenderedAnimation;
179    /// use dotmax::BrailleGrid;
180    ///
181    /// let mut animation = PrerenderedAnimation::new(30);
182    ///
183    /// // Add frames with chaining
184    /// animation
185    ///     .add_frame(BrailleGrid::new(10, 5).unwrap())
186    ///     .add_frame(BrailleGrid::new(10, 5).unwrap())
187    ///     .add_frame(BrailleGrid::new(10, 5).unwrap());
188    ///
189    /// assert_eq!(animation.frame_count(), 3);
190    /// ```
191    pub fn add_frame(&mut self, frame: BrailleGrid) -> &mut Self {
192        self.frames.push(frame);
193        self
194    }
195
196    /// Returns the number of stored frames.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use dotmax::animation::PrerenderedAnimation;
202    /// use dotmax::BrailleGrid;
203    ///
204    /// let animation = PrerenderedAnimation::new(30);
205    /// assert_eq!(animation.frame_count(), 0);
206    ///
207    /// let mut animation = PrerenderedAnimation::new(30);
208    /// animation.add_frame(BrailleGrid::new(10, 5).unwrap());
209    /// assert_eq!(animation.frame_count(), 1);
210    /// ```
211    #[must_use]
212    pub fn frame_count(&self) -> usize {
213        self.frames.len()
214    }
215
216    /// Returns the target frame rate (FPS).
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use dotmax::animation::PrerenderedAnimation;
222    ///
223    /// let animation = PrerenderedAnimation::new(60);
224    /// assert_eq!(animation.frame_rate(), 60);
225    /// ```
226    #[must_use]
227    pub const fn frame_rate(&self) -> u32 {
228        self.frame_rate
229    }
230
231    /// Plays the animation once from start to finish.
232    ///
233    /// Renders all frames at the specified frame rate using [`FrameTimer`](super::FrameTimer)
234    /// for consistent timing. Returns immediately if no frames are stored.
235    ///
236    /// # Arguments
237    ///
238    /// * `renderer` - The [`TerminalRenderer`] to render frames to
239    ///
240    /// # Returns
241    ///
242    /// * `Ok(())` - Animation played successfully
243    /// * `Err(DotmaxError)` - Rendering failed
244    ///
245    /// # Errors
246    ///
247    /// Returns [`DotmaxError::Terminal`] if rendering to the terminal fails.
248    ///
249    /// # Examples
250    ///
251    /// ```no_run
252    /// use dotmax::animation::PrerenderedAnimation;
253    /// use dotmax::BrailleGrid;
254    /// use dotmax::TerminalRenderer;
255    ///
256    /// let mut animation = PrerenderedAnimation::new(30);
257    /// // ... add frames ...
258    ///
259    /// let mut renderer = TerminalRenderer::new()?;
260    /// animation.play(&mut renderer)?;
261    /// # Ok::<(), dotmax::DotmaxError>(())
262    /// ```
263    pub fn play(&self, renderer: &mut TerminalRenderer) -> Result<(), DotmaxError> {
264        if self.frames.is_empty() {
265            debug!("play() called with empty animation, returning immediately");
266            return Ok(());
267        }
268
269        debug!(
270            frame_count = self.frames.len(),
271            frame_rate = self.frame_rate,
272            "Starting single playback"
273        );
274
275        let mut timer = FrameTimer::new(self.frame_rate);
276
277        for (i, frame) in self.frames.iter().enumerate() {
278            renderer.render(frame)?;
279            debug!(frame = i, "Rendered frame");
280            timer.wait_for_next_frame();
281        }
282
283        debug!("Single playback complete");
284        Ok(())
285    }
286
287    /// Plays the animation in a continuous loop until Ctrl+C is pressed.
288    ///
289    /// Loops seamlessly with no pause between repetitions. Stops gracefully
290    /// when Ctrl+C is detected, returning `Ok(())` rather than an error.
291    ///
292    /// # Arguments
293    ///
294    /// * `renderer` - The [`TerminalRenderer`] to render frames to
295    ///
296    /// # Returns
297    ///
298    /// * `Ok(())` - Animation stopped (either Ctrl+C or empty)
299    /// * `Err(DotmaxError)` - Rendering failed
300    ///
301    /// # Errors
302    ///
303    /// Returns [`DotmaxError::Terminal`] if rendering to the terminal fails.
304    ///
305    /// # Ctrl+C Handling
306    ///
307    /// The loop checks for Ctrl+C before each frame using non-blocking event polling.
308    /// When detected, the function returns `Ok(())` gracefully - not an error.
309    ///
310    /// # Examples
311    ///
312    /// ```no_run
313    /// use dotmax::animation::PrerenderedAnimation;
314    /// use dotmax::BrailleGrid;
315    /// use dotmax::TerminalRenderer;
316    ///
317    /// let mut animation = PrerenderedAnimation::new(30);
318    /// // ... add frames ...
319    ///
320    /// let mut renderer = TerminalRenderer::new()?;
321    /// println!("Press Ctrl+C to stop");
322    /// animation.play_loop(&mut renderer)?;  // Runs until Ctrl+C
323    /// # Ok::<(), dotmax::DotmaxError>(())
324    /// ```
325    pub fn play_loop(&self, renderer: &mut TerminalRenderer) -> Result<(), DotmaxError> {
326        if self.frames.is_empty() {
327            debug!("play_loop() called with empty animation, returning immediately");
328            return Ok(());
329        }
330
331        debug!(
332            frame_count = self.frames.len(),
333            frame_rate = self.frame_rate,
334            "Starting looped playback"
335        );
336
337        let mut timer = FrameTimer::new(self.frame_rate);
338        let mut loop_count: u64 = 0;
339
340        'outer: loop {
341            loop_count += 1;
342            debug!(loop_iteration = loop_count, "Starting animation loop");
343
344            for (i, frame) in self.frames.iter().enumerate() {
345                // Check for Ctrl+C with non-blocking poll
346                if event::poll(Duration::ZERO)? {
347                    if let Event::Key(key) = event::read()? {
348                        if key.code == KeyCode::Char('c')
349                            && key.modifiers.contains(KeyModifiers::CONTROL)
350                        {
351                            debug!(
352                                loops_completed = loop_count,
353                                frame = i,
354                                "Ctrl+C detected, stopping playback"
355                            );
356                            break 'outer;
357                        }
358                    }
359                }
360
361                renderer.render(frame)?;
362                timer.wait_for_next_frame();
363            }
364        }
365
366        debug!(total_loops = loop_count, "Looped playback stopped");
367        Ok(())
368    }
369
370    /// Saves the animation to a file.
371    ///
372    /// Uses a simple binary format (see module documentation for details).
373    /// Creates parent directories if they don't exist.
374    ///
375    /// # Arguments
376    ///
377    /// * `path` - The file path to save to
378    ///
379    /// # Returns
380    ///
381    /// * `Ok(())` - Animation saved successfully
382    /// * `Err(DotmaxError)` - I/O error occurred
383    ///
384    /// # Errors
385    ///
386    /// Returns [`DotmaxError::Terminal`] (wrapping `io::Error`) if:
387    /// - Directory creation fails
388    /// - File creation fails
389    /// - Write operations fail
390    ///
391    /// # File Format
392    ///
393    /// See module-level documentation for the complete file format specification.
394    ///
395    /// # Examples
396    ///
397    /// ```no_run
398    /// use dotmax::animation::PrerenderedAnimation;
399    /// use dotmax::BrailleGrid;
400    /// use std::path::Path;
401    ///
402    /// let mut animation = PrerenderedAnimation::new(30);
403    /// animation.add_frame(BrailleGrid::new(80, 24).unwrap());
404    ///
405    /// animation.save_to_file(Path::new("my_animation.dmax"))?;
406    /// # Ok::<(), dotmax::DotmaxError>(())
407    /// ```
408    pub fn save_to_file(&self, path: &Path) -> Result<(), DotmaxError> {
409        debug!(path = ?path, frames = self.frames.len(), "Saving animation to file");
410
411        // Create parent directories if needed
412        if let Some(parent) = path.parent() {
413            if !parent.as_os_str().is_empty() {
414                std::fs::create_dir_all(parent)?;
415            }
416        }
417
418        let file = File::create(path)?;
419        let mut writer = BufWriter::new(file);
420
421        // Determine dimensions from first frame (or use 0x0 for empty)
422        let (width, height) = self
423            .frames
424            .first()
425            .map_or((0, 0), BrailleGrid::dimensions);
426
427        // Write header
428        writer.write_all(MAGIC)?;
429        writer.write_all(&[VERSION])?;
430        writer.write_all(&self.frame_rate.to_le_bytes())?;
431        #[allow(clippy::cast_possible_truncation)]
432        let frame_count = self.frames.len() as u32;
433        writer.write_all(&frame_count.to_le_bytes())?;
434        #[allow(clippy::cast_possible_truncation)]
435        let width_u32 = width as u32;
436        #[allow(clippy::cast_possible_truncation)]
437        let height_u32 = height as u32;
438        writer.write_all(&width_u32.to_le_bytes())?;
439        writer.write_all(&height_u32.to_le_bytes())?;
440
441        // Write frame data
442        for frame in &self.frames {
443            let data = frame.get_raw_patterns();
444            writer.write_all(data)?;
445        }
446
447        writer.flush()?;
448        debug!(path = ?path, "Animation saved successfully");
449        Ok(())
450    }
451
452    /// Loads an animation from a file.
453    ///
454    /// Validates the file format and returns appropriate errors for invalid files.
455    ///
456    /// # Arguments
457    ///
458    /// * `path` - The file path to load from
459    ///
460    /// # Returns
461    ///
462    /// * `Ok(PrerenderedAnimation)` - Animation loaded successfully
463    /// * `Err(DotmaxError)` - File not found, invalid format, or I/O error
464    ///
465    /// # Errors
466    ///
467    /// Returns [`DotmaxError::Terminal`] (wrapping `io::Error`) if:
468    /// - File not found
469    /// - Permission denied
470    /// - Invalid magic bytes (not a DMAX file)
471    /// - Truncated or corrupted data
472    ///
473    /// # Examples
474    ///
475    /// ```no_run
476    /// use dotmax::animation::PrerenderedAnimation;
477    /// use std::path::Path;
478    ///
479    /// let animation = PrerenderedAnimation::load_from_file(Path::new("my_animation.dmax"))?;
480    /// println!("Loaded {} frames at {} FPS", animation.frame_count(), animation.frame_rate());
481    /// # Ok::<(), dotmax::DotmaxError>(())
482    /// ```
483    pub fn load_from_file(path: &Path) -> Result<Self, DotmaxError> {
484        debug!(path = ?path, "Loading animation from file");
485
486        let file = File::open(path)?;
487        let mut reader = BufReader::new(file);
488
489        // Read and validate magic bytes
490        let mut magic = [0u8; 4];
491        reader.read_exact(&mut magic)?;
492        if &magic != MAGIC {
493            return Err(DotmaxError::Terminal(std::io::Error::new(
494                std::io::ErrorKind::InvalidData,
495                format!("Invalid magic bytes: expected {MAGIC:?}, got {magic:?}"),
496            )));
497        }
498
499        // Read version
500        let mut version = [0u8; 1];
501        reader.read_exact(&mut version)?;
502        let file_version = version[0];
503        if file_version != VERSION {
504            return Err(DotmaxError::Terminal(std::io::Error::new(
505                std::io::ErrorKind::InvalidData,
506                format!("Unsupported file version: expected {VERSION}, got {file_version}"),
507            )));
508        }
509
510        // Read header fields
511        let mut frame_rate_bytes = [0u8; 4];
512        reader.read_exact(&mut frame_rate_bytes)?;
513        let frame_rate = u32::from_le_bytes(frame_rate_bytes);
514
515        let mut frame_count_bytes = [0u8; 4];
516        reader.read_exact(&mut frame_count_bytes)?;
517        let frame_count = u32::from_le_bytes(frame_count_bytes);
518
519        let mut width_bytes = [0u8; 4];
520        reader.read_exact(&mut width_bytes)?;
521        let width = u32::from_le_bytes(width_bytes) as usize;
522
523        let mut height_bytes = [0u8; 4];
524        reader.read_exact(&mut height_bytes)?;
525        let height = u32::from_le_bytes(height_bytes) as usize;
526
527        debug!(
528            frame_rate = frame_rate,
529            frame_count = frame_count,
530            width = width,
531            height = height,
532            "Read animation header"
533        );
534
535        // Read frames
536        let mut frames = Vec::with_capacity(frame_count as usize);
537        let frame_size = width * height;
538
539        for i in 0..frame_count {
540            let mut data = vec![0u8; frame_size];
541            reader.read_exact(&mut data)?;
542
543            // Create BrailleGrid and populate with data
544            let mut grid = BrailleGrid::new(width, height)?;
545            grid.set_raw_patterns(&data);
546            frames.push(grid);
547
548            debug!(frame = i, "Loaded frame");
549        }
550
551        debug!(path = ?path, frames = frames.len(), "Animation loaded successfully");
552
553        Ok(Self {
554            frames,
555            frame_rate: frame_rate.clamp(MIN_FPS, MAX_FPS),
556        })
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use std::io::Write;
564    use tempfile::NamedTempFile;
565
566    // ========================================================================
567    // AC #1: Constructor Tests
568    // ========================================================================
569
570    #[test]
571    fn test_new_creates_empty_animation() {
572        let animation = PrerenderedAnimation::new(30);
573        assert_eq!(animation.frame_count(), 0);
574        assert_eq!(animation.frame_rate(), 30);
575    }
576
577    #[test]
578    fn test_new_clamps_fps_below_min() {
579        let animation = PrerenderedAnimation::new(0);
580        assert_eq!(animation.frame_rate(), 1);
581    }
582
583    #[test]
584    fn test_new_clamps_fps_above_max() {
585        let animation = PrerenderedAnimation::new(1000);
586        assert_eq!(animation.frame_rate(), 240);
587    }
588
589    #[test]
590    fn test_new_at_min_boundary() {
591        let animation = PrerenderedAnimation::new(1);
592        assert_eq!(animation.frame_rate(), 1);
593    }
594
595    #[test]
596    fn test_new_at_max_boundary() {
597        let animation = PrerenderedAnimation::new(240);
598        assert_eq!(animation.frame_rate(), 240);
599    }
600
601    // ========================================================================
602    // AC #2: add_frame() Tests
603    // ========================================================================
604
605    #[test]
606    fn test_add_frame_increments_count() {
607        let mut animation = PrerenderedAnimation::new(30);
608        assert_eq!(animation.frame_count(), 0);
609
610        let grid = BrailleGrid::new(10, 5).unwrap();
611        animation.add_frame(grid);
612        assert_eq!(animation.frame_count(), 1);
613    }
614
615    #[test]
616    fn test_add_frame_chaining_works() {
617        let mut animation = PrerenderedAnimation::new(30);
618        animation
619            .add_frame(BrailleGrid::new(10, 5).unwrap())
620            .add_frame(BrailleGrid::new(10, 5).unwrap())
621            .add_frame(BrailleGrid::new(10, 5).unwrap());
622
623        assert_eq!(animation.frame_count(), 3);
624    }
625
626    #[test]
627    fn test_add_frame_accepts_different_sizes() {
628        let mut animation = PrerenderedAnimation::new(30);
629        animation
630            .add_frame(BrailleGrid::new(10, 5).unwrap())
631            .add_frame(BrailleGrid::new(20, 10).unwrap())
632            .add_frame(BrailleGrid::new(5, 3).unwrap());
633
634        assert_eq!(animation.frame_count(), 3);
635    }
636
637    // ========================================================================
638    // AC #5: frame_count() Tests
639    // ========================================================================
640
641    #[test]
642    fn test_frame_count_returns_zero_for_empty() {
643        let animation = PrerenderedAnimation::new(30);
644        assert_eq!(animation.frame_count(), 0);
645    }
646
647    #[test]
648    fn test_frame_count_returns_correct_value() {
649        let mut animation = PrerenderedAnimation::new(30);
650        for _ in 0..5 {
651            animation.add_frame(BrailleGrid::new(10, 5).unwrap());
652        }
653        assert_eq!(animation.frame_count(), 5);
654    }
655
656    // ========================================================================
657    // AC #6, AC #7: File I/O Tests
658    // ========================================================================
659
660    #[test]
661    fn test_save_load_roundtrip_preserves_data() {
662        let mut animation = PrerenderedAnimation::new(30);
663
664        // Add frames with some data
665        for i in 0..3 {
666            let mut grid = BrailleGrid::new(10, 5).unwrap();
667            // Set a dot at position based on frame number
668            grid.set_dot(i * 2, 0).unwrap();
669            animation.add_frame(grid);
670        }
671
672        // Save to temp file
673        let temp_file = NamedTempFile::new().unwrap();
674        let path = temp_file.path();
675        animation.save_to_file(path).unwrap();
676
677        // Load back
678        let loaded = PrerenderedAnimation::load_from_file(path).unwrap();
679
680        // Verify
681        assert_eq!(loaded.frame_rate(), 30);
682        assert_eq!(loaded.frame_count(), 3);
683    }
684
685    #[test]
686    fn test_load_with_invalid_magic_returns_error() {
687        // Create file with wrong magic bytes
688        let mut temp_file = NamedTempFile::new().unwrap();
689        temp_file.write_all(b"BADX").unwrap();
690        temp_file.write_all(&[1u8]).unwrap(); // version
691        temp_file.write_all(&30u32.to_le_bytes()).unwrap(); // fps
692        temp_file.write_all(&0u32.to_le_bytes()).unwrap(); // frame count
693        temp_file.write_all(&10u32.to_le_bytes()).unwrap(); // width
694        temp_file.write_all(&5u32.to_le_bytes()).unwrap(); // height
695        temp_file.flush().unwrap();
696
697        let result = PrerenderedAnimation::load_from_file(temp_file.path());
698        assert!(result.is_err());
699    }
700
701    #[test]
702    fn test_load_nonexistent_file_returns_error() {
703        let result = PrerenderedAnimation::load_from_file(Path::new("/nonexistent/path/file.dmax"));
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_load_truncated_file_returns_error() {
709        // Create file with truncated header
710        let mut temp_file = NamedTempFile::new().unwrap();
711        temp_file.write_all(b"DMAX").unwrap();
712        // Missing version and other header fields
713        temp_file.flush().unwrap();
714
715        let result = PrerenderedAnimation::load_from_file(temp_file.path());
716        assert!(result.is_err());
717    }
718
719    #[test]
720    fn test_save_empty_animation() {
721        let animation = PrerenderedAnimation::new(60);
722
723        let temp_file = NamedTempFile::new().unwrap();
724        let result = animation.save_to_file(temp_file.path());
725        assert!(result.is_ok());
726
727        // Load and verify empty
728        let loaded = PrerenderedAnimation::load_from_file(temp_file.path()).unwrap();
729        assert_eq!(loaded.frame_count(), 0);
730        assert_eq!(loaded.frame_rate(), 60);
731    }
732
733    #[test]
734    fn test_save_creates_parent_directories() {
735        let temp_dir = tempfile::tempdir().unwrap();
736        let path = temp_dir.path().join("subdir/nested/animation.dmax");
737
738        let animation = PrerenderedAnimation::new(30);
739        let result = animation.save_to_file(&path);
740        assert!(result.is_ok());
741        assert!(path.exists());
742    }
743}