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}