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}