Skip to main content

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.frames.first().map_or((0, 0), BrailleGrid::dimensions);
423
424        // Write header
425        writer.write_all(MAGIC)?;
426        writer.write_all(&[VERSION])?;
427        writer.write_all(&self.frame_rate.to_le_bytes())?;
428        #[allow(clippy::cast_possible_truncation)]
429        let frame_count = self.frames.len() as u32;
430        writer.write_all(&frame_count.to_le_bytes())?;
431        #[allow(clippy::cast_possible_truncation)]
432        let width_u32 = width as u32;
433        #[allow(clippy::cast_possible_truncation)]
434        let height_u32 = height as u32;
435        writer.write_all(&width_u32.to_le_bytes())?;
436        writer.write_all(&height_u32.to_le_bytes())?;
437
438        // Write frame data
439        for frame in &self.frames {
440            let data = frame.get_raw_patterns();
441            writer.write_all(data)?;
442        }
443
444        writer.flush()?;
445        debug!(path = ?path, "Animation saved successfully");
446        Ok(())
447    }
448
449    /// Loads an animation from a file.
450    ///
451    /// Validates the file format and returns appropriate errors for invalid files.
452    ///
453    /// # Arguments
454    ///
455    /// * `path` - The file path to load from
456    ///
457    /// # Returns
458    ///
459    /// * `Ok(PrerenderedAnimation)` - Animation loaded successfully
460    /// * `Err(DotmaxError)` - File not found, invalid format, or I/O error
461    ///
462    /// # Errors
463    ///
464    /// Returns [`DotmaxError::Terminal`] (wrapping `io::Error`) if:
465    /// - File not found
466    /// - Permission denied
467    /// - Invalid magic bytes (not a DMAX file)
468    /// - Truncated or corrupted data
469    ///
470    /// # Examples
471    ///
472    /// ```no_run
473    /// use dotmax::animation::PrerenderedAnimation;
474    /// use std::path::Path;
475    ///
476    /// let animation = PrerenderedAnimation::load_from_file(Path::new("my_animation.dmax"))?;
477    /// println!("Loaded {} frames at {} FPS", animation.frame_count(), animation.frame_rate());
478    /// # Ok::<(), dotmax::DotmaxError>(())
479    /// ```
480    pub fn load_from_file(path: &Path) -> Result<Self, DotmaxError> {
481        debug!(path = ?path, "Loading animation from file");
482
483        let file = File::open(path)?;
484        let mut reader = BufReader::new(file);
485
486        // Read and validate magic bytes
487        let mut magic = [0u8; 4];
488        reader.read_exact(&mut magic)?;
489        if &magic != MAGIC {
490            return Err(DotmaxError::Terminal(std::io::Error::new(
491                std::io::ErrorKind::InvalidData,
492                format!("Invalid magic bytes: expected {MAGIC:?}, got {magic:?}"),
493            )));
494        }
495
496        // Read version
497        let mut version = [0u8; 1];
498        reader.read_exact(&mut version)?;
499        let file_version = version[0];
500        if file_version != VERSION {
501            return Err(DotmaxError::Terminal(std::io::Error::new(
502                std::io::ErrorKind::InvalidData,
503                format!("Unsupported file version: expected {VERSION}, got {file_version}"),
504            )));
505        }
506
507        // Read header fields
508        let mut frame_rate_bytes = [0u8; 4];
509        reader.read_exact(&mut frame_rate_bytes)?;
510        let frame_rate = u32::from_le_bytes(frame_rate_bytes);
511
512        let mut frame_count_bytes = [0u8; 4];
513        reader.read_exact(&mut frame_count_bytes)?;
514        let frame_count = u32::from_le_bytes(frame_count_bytes);
515
516        let mut width_bytes = [0u8; 4];
517        reader.read_exact(&mut width_bytes)?;
518        let width = u32::from_le_bytes(width_bytes) as usize;
519
520        let mut height_bytes = [0u8; 4];
521        reader.read_exact(&mut height_bytes)?;
522        let height = u32::from_le_bytes(height_bytes) as usize;
523
524        debug!(
525            frame_rate = frame_rate,
526            frame_count = frame_count,
527            width = width,
528            height = height,
529            "Read animation header"
530        );
531
532        // Read frames
533        let mut frames = Vec::with_capacity(frame_count as usize);
534        let frame_size = width * height;
535
536        for i in 0..frame_count {
537            let mut data = vec![0u8; frame_size];
538            reader.read_exact(&mut data)?;
539
540            // Create BrailleGrid and populate with data
541            let mut grid = BrailleGrid::new(width, height)?;
542            grid.set_raw_patterns(&data);
543            frames.push(grid);
544
545            debug!(frame = i, "Loaded frame");
546        }
547
548        debug!(path = ?path, frames = frames.len(), "Animation loaded successfully");
549
550        Ok(Self {
551            frames,
552            frame_rate: frame_rate.clamp(MIN_FPS, MAX_FPS),
553        })
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use std::io::Write;
561    use tempfile::NamedTempFile;
562
563    // ========================================================================
564    // AC #1: Constructor Tests
565    // ========================================================================
566
567    #[test]
568    fn test_new_creates_empty_animation() {
569        let animation = PrerenderedAnimation::new(30);
570        assert_eq!(animation.frame_count(), 0);
571        assert_eq!(animation.frame_rate(), 30);
572    }
573
574    #[test]
575    fn test_new_clamps_fps_below_min() {
576        let animation = PrerenderedAnimation::new(0);
577        assert_eq!(animation.frame_rate(), 1);
578    }
579
580    #[test]
581    fn test_new_clamps_fps_above_max() {
582        let animation = PrerenderedAnimation::new(1000);
583        assert_eq!(animation.frame_rate(), 240);
584    }
585
586    #[test]
587    fn test_new_at_min_boundary() {
588        let animation = PrerenderedAnimation::new(1);
589        assert_eq!(animation.frame_rate(), 1);
590    }
591
592    #[test]
593    fn test_new_at_max_boundary() {
594        let animation = PrerenderedAnimation::new(240);
595        assert_eq!(animation.frame_rate(), 240);
596    }
597
598    // ========================================================================
599    // AC #2: add_frame() Tests
600    // ========================================================================
601
602    #[test]
603    fn test_add_frame_increments_count() {
604        let mut animation = PrerenderedAnimation::new(30);
605        assert_eq!(animation.frame_count(), 0);
606
607        let grid = BrailleGrid::new(10, 5).unwrap();
608        animation.add_frame(grid);
609        assert_eq!(animation.frame_count(), 1);
610    }
611
612    #[test]
613    fn test_add_frame_chaining_works() {
614        let mut animation = PrerenderedAnimation::new(30);
615        animation
616            .add_frame(BrailleGrid::new(10, 5).unwrap())
617            .add_frame(BrailleGrid::new(10, 5).unwrap())
618            .add_frame(BrailleGrid::new(10, 5).unwrap());
619
620        assert_eq!(animation.frame_count(), 3);
621    }
622
623    #[test]
624    fn test_add_frame_accepts_different_sizes() {
625        let mut animation = PrerenderedAnimation::new(30);
626        animation
627            .add_frame(BrailleGrid::new(10, 5).unwrap())
628            .add_frame(BrailleGrid::new(20, 10).unwrap())
629            .add_frame(BrailleGrid::new(5, 3).unwrap());
630
631        assert_eq!(animation.frame_count(), 3);
632    }
633
634    // ========================================================================
635    // AC #5: frame_count() Tests
636    // ========================================================================
637
638    #[test]
639    fn test_frame_count_returns_zero_for_empty() {
640        let animation = PrerenderedAnimation::new(30);
641        assert_eq!(animation.frame_count(), 0);
642    }
643
644    #[test]
645    fn test_frame_count_returns_correct_value() {
646        let mut animation = PrerenderedAnimation::new(30);
647        for _ in 0..5 {
648            animation.add_frame(BrailleGrid::new(10, 5).unwrap());
649        }
650        assert_eq!(animation.frame_count(), 5);
651    }
652
653    // ========================================================================
654    // AC #6, AC #7: File I/O Tests
655    // ========================================================================
656
657    #[test]
658    fn test_save_load_roundtrip_preserves_data() {
659        let mut animation = PrerenderedAnimation::new(30);
660
661        // Add frames with some data
662        for i in 0..3 {
663            let mut grid = BrailleGrid::new(10, 5).unwrap();
664            // Set a dot at position based on frame number
665            grid.set_dot(i * 2, 0).unwrap();
666            animation.add_frame(grid);
667        }
668
669        // Save to temp file
670        let temp_file = NamedTempFile::new().unwrap();
671        let path = temp_file.path();
672        animation.save_to_file(path).unwrap();
673
674        // Load back
675        let loaded = PrerenderedAnimation::load_from_file(path).unwrap();
676
677        // Verify
678        assert_eq!(loaded.frame_rate(), 30);
679        assert_eq!(loaded.frame_count(), 3);
680    }
681
682    #[test]
683    fn test_load_with_invalid_magic_returns_error() {
684        // Create file with wrong magic bytes
685        let mut temp_file = NamedTempFile::new().unwrap();
686        temp_file.write_all(b"BADX").unwrap();
687        temp_file.write_all(&[1u8]).unwrap(); // version
688        temp_file.write_all(&30u32.to_le_bytes()).unwrap(); // fps
689        temp_file.write_all(&0u32.to_le_bytes()).unwrap(); // frame count
690        temp_file.write_all(&10u32.to_le_bytes()).unwrap(); // width
691        temp_file.write_all(&5u32.to_le_bytes()).unwrap(); // height
692        temp_file.flush().unwrap();
693
694        let result = PrerenderedAnimation::load_from_file(temp_file.path());
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_load_nonexistent_file_returns_error() {
700        let result = PrerenderedAnimation::load_from_file(Path::new("/nonexistent/path/file.dmax"));
701        assert!(result.is_err());
702    }
703
704    #[test]
705    fn test_load_truncated_file_returns_error() {
706        // Create file with truncated header
707        let mut temp_file = NamedTempFile::new().unwrap();
708        temp_file.write_all(b"DMAX").unwrap();
709        // Missing version and other header fields
710        temp_file.flush().unwrap();
711
712        let result = PrerenderedAnimation::load_from_file(temp_file.path());
713        assert!(result.is_err());
714    }
715
716    #[test]
717    fn test_save_empty_animation() {
718        let animation = PrerenderedAnimation::new(60);
719
720        let temp_file = NamedTempFile::new().unwrap();
721        let result = animation.save_to_file(temp_file.path());
722        assert!(result.is_ok());
723
724        // Load and verify empty
725        let loaded = PrerenderedAnimation::load_from_file(temp_file.path()).unwrap();
726        assert_eq!(loaded.frame_count(), 0);
727        assert_eq!(loaded.frame_rate(), 60);
728    }
729
730    #[test]
731    fn test_save_creates_parent_directories() {
732        let temp_dir = tempfile::tempdir().unwrap();
733        let path = temp_dir.path().join("subdir/nested/animation.dmax");
734
735        let animation = PrerenderedAnimation::new(30);
736        let result = animation.save_to_file(&path);
737        assert!(result.is_ok());
738        assert!(path.exists());
739    }
740}