Skip to main content

ftui_harness/
time_travel.rs

1#![forbid(unsafe_code)]
2
3//! Time-travel debugging with frame snapshots.
4//!
5//! Records compressed frame snapshots during development, enabling "rewind"
6//! to inspect past visual states. Frames are delta-encoded for efficient
7//! memory usage.
8//!
9//! # Quick Start
10//!
11//! ```
12//! use ftui_harness::time_travel::{TimeTravel, FrameMetadata};
13//! use ftui_render::buffer::Buffer;
14//! use ftui_render::cell::Cell;
15//! use std::time::Duration;
16//!
17//! let mut tt = TimeTravel::new(100);
18//!
19//! // Record frames
20//! let mut buf = Buffer::new(10, 5);
21//! buf.set(0, 0, Cell::from_char('A'));
22//! tt.record(&buf, FrameMetadata::new(0, Duration::from_millis(2)));
23//!
24//! buf.set(1, 0, Cell::from_char('B'));
25//! tt.record(&buf, FrameMetadata::new(1, Duration::from_millis(3)));
26//!
27//! // Rewind
28//! let frame = tt.rewind(0).unwrap(); // current frame
29//! assert_eq!(frame.get(1, 0).unwrap().content.as_char(), Some('B'));
30//!
31//! let prev = tt.rewind(1).unwrap(); // one step back
32//! assert!(prev.get(1, 0).unwrap().is_empty());
33//! ```
34//!
35//! # Export
36//!
37//! ```ignore
38//! tt.export(Path::new("session.fttr"))?;
39//! let loaded = TimeTravel::import(Path::new("session.fttr"))?;
40//! ```
41
42use std::collections::VecDeque;
43use std::io::{self, Read, Write};
44use std::path::Path;
45use std::time::Duration;
46
47use ftui_render::buffer::Buffer;
48use ftui_render::cell::Cell;
49
50// ============================================================================
51// Cell Change
52// ============================================================================
53
54/// A single changed cell in a frame delta.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct CellChange {
57    pub x: u16,
58    pub y: u16,
59    pub cell: Cell,
60}
61
62impl CellChange {
63    /// Serialized size: x(2) + y(2) + cell content(4) + fg(4) + bg(4) + attrs(4) = 20 bytes.
64    #[cfg(test)]
65    const SERIALIZED_SIZE: usize = 20;
66
67    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
68        w.write_all(&self.x.to_le_bytes())?;
69        w.write_all(&self.y.to_le_bytes())?;
70        w.write_all(&self.cell.content.raw().to_le_bytes())?;
71        w.write_all(&self.cell.fg.0.to_le_bytes())?;
72        w.write_all(&self.cell.bg.0.to_le_bytes())?;
73        // CellAttrs: reconstruct from flags + link_id
74        let flags_byte = self.cell.attrs.flags().bits();
75        let link_id = self.cell.attrs.link_id();
76        let attrs_raw = ((flags_byte as u32) << 24) | (link_id & 0x00FF_FFFF);
77        w.write_all(&attrs_raw.to_le_bytes())
78    }
79
80    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
81        let mut buf2 = [0u8; 2];
82        let mut buf4 = [0u8; 4];
83
84        r.read_exact(&mut buf2)?;
85        let x = u16::from_le_bytes(buf2);
86        r.read_exact(&mut buf2)?;
87        let y = u16::from_le_bytes(buf2);
88
89        r.read_exact(&mut buf4)?;
90        let content_raw = u32::from_le_bytes(buf4);
91        r.read_exact(&mut buf4)?;
92        let fg_raw = u32::from_le_bytes(buf4);
93        r.read_exact(&mut buf4)?;
94        let bg_raw = u32::from_le_bytes(buf4);
95        r.read_exact(&mut buf4)?;
96        let attrs_raw = u32::from_le_bytes(buf4);
97
98        use ftui_render::cell::{CellAttrs, CellContent, GraphemeId, PackedRgba, StyleFlags};
99
100        // Reconstruct CellContent from raw u32.
101        let content = if content_raw & 0x8000_0000 != 0 {
102            CellContent::from_grapheme(GraphemeId::from_raw(content_raw & !0x8000_0000))
103        } else if content_raw == CellContent::EMPTY.raw() {
104            CellContent::EMPTY
105        } else if content_raw == CellContent::CONTINUATION.raw() {
106            CellContent::CONTINUATION
107        } else {
108            char::from_u32(content_raw)
109                .map(CellContent::from_char)
110                .unwrap_or(CellContent::EMPTY)
111        };
112        let fg = PackedRgba(fg_raw);
113        let bg = PackedRgba(bg_raw);
114        let flags = StyleFlags::from_bits_truncate((attrs_raw >> 24) as u8);
115        let link_id = attrs_raw & 0x00FF_FFFF;
116        let attrs = CellAttrs::new(flags, link_id);
117
118        Ok(CellChange {
119            x,
120            y,
121            cell: Cell {
122                content,
123                fg,
124                bg,
125                attrs,
126            },
127        })
128    }
129}
130
131// ============================================================================
132// Compressed Frame
133// ============================================================================
134
135/// A delta-encoded frame snapshot.
136///
137/// Stores only the cells that changed from the previous frame.
138/// The first frame in a recording is always a full snapshot
139/// (all non-empty cells are stored as changes from an empty buffer).
140#[derive(Debug, Clone)]
141pub struct CompressedFrame {
142    /// Buffer dimensions (needed to reconstruct).
143    width: u16,
144    height: u16,
145    /// Changed cells (sparse delta).
146    changes: Vec<CellChange>,
147    /// Cursor position at time of snapshot.
148    cursor: Option<(u16, u16)>,
149}
150
151impl CompressedFrame {
152    /// Create a full snapshot (delta from empty buffer).
153    pub fn full(buf: &Buffer) -> Self {
154        let mut changes = Vec::new();
155        let default = Cell::default();
156
157        for y in 0..buf.height() {
158            for x in 0..buf.width() {
159                let cell = buf.get(x, y).unwrap();
160                if !cell.bits_eq(&default) {
161                    changes.push(CellChange { x, y, cell: *cell });
162                }
163            }
164        }
165
166        Self {
167            width: buf.width(),
168            height: buf.height(),
169            changes,
170            cursor: None,
171        }
172    }
173
174    /// Create a delta-encoded snapshot from the previous buffer state.
175    pub fn delta(current: &Buffer, previous: &Buffer) -> Self {
176        debug_assert_eq!(current.width(), previous.width());
177        debug_assert_eq!(current.height(), previous.height());
178
179        let mut changes = Vec::new();
180
181        for y in 0..current.height() {
182            for x in 0..current.width() {
183                let curr = current.get(x, y).unwrap();
184                let prev = previous.get(x, y).unwrap();
185                if !curr.bits_eq(prev) {
186                    changes.push(CellChange { x, y, cell: *curr });
187                }
188            }
189        }
190
191        Self {
192            width: current.width(),
193            height: current.height(),
194            changes,
195            cursor: None,
196        }
197    }
198
199    /// Apply this frame's changes to a buffer.
200    ///
201    /// The buffer must have matching dimensions.
202    pub fn apply_to(&self, buf: &mut Buffer) {
203        debug_assert_eq!(buf.width(), self.width);
204        debug_assert_eq!(buf.height(), self.height);
205
206        for change in &self.changes {
207            buf.set_raw(change.x, change.y, change.cell);
208        }
209    }
210
211    /// Number of changed cells in this frame.
212    pub fn change_count(&self) -> usize {
213        self.changes.len()
214    }
215
216    /// Estimated memory size in bytes.
217    pub fn memory_size(&self) -> usize {
218        std::mem::size_of::<Self>() + self.changes.len() * std::mem::size_of::<CellChange>()
219    }
220
221    /// Set cursor position for this frame.
222    #[must_use]
223    pub fn with_cursor(mut self, cursor: Option<(u16, u16)>) -> Self {
224        self.cursor = cursor;
225        self
226    }
227}
228
229// ============================================================================
230// Frame Metadata
231// ============================================================================
232
233/// Metadata attached to each recorded frame.
234#[derive(Debug, Clone)]
235pub struct FrameMetadata {
236    /// Frame sequence number.
237    pub frame_number: u64,
238    /// Time taken to render this frame.
239    pub render_time: Duration,
240    /// Number of events that triggered this frame.
241    pub event_count: u32,
242    /// Optional model state hash for identifying state.
243    pub model_hash: Option<u64>,
244}
245
246impl FrameMetadata {
247    /// Create metadata with required fields.
248    pub fn new(frame_number: u64, render_time: Duration) -> Self {
249        Self {
250            frame_number,
251            render_time,
252            event_count: 0,
253            model_hash: None,
254        }
255    }
256
257    /// Set event count.
258    #[must_use]
259    pub fn with_events(mut self, count: u32) -> Self {
260        self.event_count = count;
261        self
262    }
263
264    /// Set model hash.
265    #[must_use]
266    pub fn with_model_hash(mut self, hash: u64) -> Self {
267        self.model_hash = Some(hash);
268        self
269    }
270}
271
272// ============================================================================
273// TimeTravel
274// ============================================================================
275
276/// Time-travel recording session.
277///
278/// Records frame snapshots in a circular buffer, enabling rewind to
279/// inspect past visual states. Frames are delta-encoded from the
280/// previous frame for efficient memory usage.
281///
282/// # Memory Budget
283///
284/// With default capacity (1000 frames) and typical delta size (~100 bytes
285/// for 5% cell change), total memory is approximately 100KB.
286#[derive(Debug)]
287pub struct TimeTravel {
288    /// Circular buffer of compressed frame snapshots.
289    snapshots: VecDeque<CompressedFrame>,
290    /// Metadata for each frame.
291    metadata: VecDeque<FrameMetadata>,
292    /// Maximum number of snapshots to retain.
293    capacity: usize,
294    /// Running frame counter.
295    frame_counter: u64,
296    /// Whether recording is active.
297    recording: bool,
298    /// The last buffer state (for computing deltas).
299    last_buffer: Option<Buffer>,
300}
301
302impl TimeTravel {
303    /// Create a new recorder with the given capacity.
304    ///
305    /// # Panics
306    ///
307    /// Panics if capacity is 0.
308    pub fn new(capacity: usize) -> Self {
309        assert!(capacity > 0, "TimeTravel capacity must be > 0");
310        Self {
311            snapshots: VecDeque::with_capacity(capacity),
312            metadata: VecDeque::with_capacity(capacity),
313            capacity,
314            frame_counter: 0,
315            recording: true,
316            last_buffer: None,
317        }
318    }
319
320    /// Record a frame snapshot.
321    ///
322    /// If recording is paused, this is a no-op. The frame is delta-encoded
323    /// from the previous recorded frame, or stored as a full snapshot if
324    /// this is the first frame.
325    pub fn record(&mut self, buf: &Buffer, metadata: FrameMetadata) {
326        if !self.recording {
327            return;
328        }
329
330        // Evict oldest if at capacity
331        if self.snapshots.len() >= self.capacity {
332            // When dropping the oldest frame (index 0), we must ensure the
333            // next frame (index 1) becomes self-contained (Full snapshot).
334            // Otherwise, it remains a Delta relative to the frame we just dropped,
335            // making it impossible to reconstruct.
336            if self.snapshots.len() > 1 {
337                let f0 = &self.snapshots[0];
338                let f1 = &self.snapshots[1];
339
340                // Reconstruct the state at frame 1
341                let mut buf = Buffer::new(f0.width, f0.height);
342                f0.apply_to(&mut buf);
343                f1.apply_to(&mut buf);
344
345                // Create a full snapshot from that state
346                let f1_full = CompressedFrame::full(&buf).with_cursor(f1.cursor);
347                self.snapshots[1] = f1_full;
348            }
349
350            self.snapshots.pop_front();
351            self.metadata.pop_front();
352        }
353
354        let compressed = if self.snapshots.is_empty() {
355            CompressedFrame::full(buf)
356        } else {
357            match &self.last_buffer {
358                Some(prev) if prev.width() == buf.width() && prev.height() == buf.height() => {
359                    CompressedFrame::delta(buf, prev)
360                }
361                _ => CompressedFrame::full(buf),
362            }
363        };
364
365        self.snapshots.push_back(compressed);
366        self.metadata.push_back(metadata);
367        self.last_buffer = Some(buf.clone());
368        self.frame_counter += 1;
369    }
370
371    /// Number of recorded snapshots.
372    #[inline]
373    pub fn len(&self) -> usize {
374        self.snapshots.len()
375    }
376
377    /// Check if no frames have been recorded.
378    #[inline]
379    pub fn is_empty(&self) -> bool {
380        self.snapshots.is_empty()
381    }
382
383    /// Maximum number of snapshots.
384    #[inline]
385    pub fn capacity(&self) -> usize {
386        self.capacity
387    }
388
389    /// Total frame counter (including evicted frames).
390    #[inline]
391    pub fn frame_counter(&self) -> u64 {
392        self.frame_counter
393    }
394
395    /// Whether recording is active.
396    pub fn is_recording(&self) -> bool {
397        self.recording
398    }
399
400    /// Pause or resume recording.
401    pub fn set_recording(&mut self, recording: bool) {
402        self.recording = recording;
403    }
404
405    /// Reconstruct the buffer at a given index (0 = oldest retained frame).
406    ///
407    /// Returns `None` if the index is out of range.
408    pub fn get(&self, index: usize) -> Option<Buffer> {
409        if index >= self.snapshots.len() {
410            return None;
411        }
412
413        // The first snapshot is always a full snapshot (or becomes one
414        // after eviction resets). We reconstruct by replaying deltas.
415        let first = &self.snapshots[0];
416        let mut buf = Buffer::new(first.width, first.height);
417        for snapshot in self.snapshots.iter().take(index + 1) {
418            snapshot.apply_to(&mut buf);
419        }
420        Some(buf)
421    }
422
423    /// Reconstruct the buffer N steps back from the most recent frame.
424    ///
425    /// `rewind(0)` returns the latest frame. `rewind(1)` returns one step back.
426    pub fn rewind(&self, steps: usize) -> Option<Buffer> {
427        let index = self.snapshots.len().checked_sub(steps + 1)?;
428        self.get(index)
429    }
430
431    /// Get metadata for a frame at the given index (0 = oldest).
432    pub fn metadata(&self, index: usize) -> Option<&FrameMetadata> {
433        self.metadata.get(index)
434    }
435
436    /// Get metadata for the most recent frame.
437    pub fn latest_metadata(&self) -> Option<&FrameMetadata> {
438        self.metadata.back()
439    }
440
441    /// Find the index of a frame by model hash.
442    ///
443    /// Returns the index of the first frame with the matching hash.
444    pub fn find_by_hash(&self, hash: u64) -> Option<usize> {
445        self.metadata
446            .iter()
447            .position(|m| m.model_hash == Some(hash))
448    }
449
450    /// Total estimated memory usage in bytes.
451    pub fn memory_usage(&self) -> usize {
452        let snapshot_mem: usize = self.snapshots.iter().map(|s| s.memory_size()).sum();
453        let metadata_mem = self.metadata.len() * std::mem::size_of::<FrameMetadata>();
454        let buf_mem = self
455            .last_buffer
456            .as_ref()
457            .map(|b| b.len() * std::mem::size_of::<Cell>())
458            .unwrap_or(0);
459        snapshot_mem + metadata_mem + buf_mem + std::mem::size_of::<Self>()
460    }
461
462    /// Clear all recorded frames.
463    pub fn clear(&mut self) {
464        self.snapshots.clear();
465        self.metadata.clear();
466        self.last_buffer = None;
467    }
468
469    // ========================================================================
470    // Export / Import
471    // ========================================================================
472
473    /// File format magic bytes.
474    const MAGIC: &'static [u8] = b"FTUI-TT1";
475
476    /// Export recording to a file.
477    ///
478    /// Format:
479    /// - 8 bytes: magic `FTUI-TT1`
480    /// - 2 bytes: width (LE)
481    /// - 2 bytes: height (LE)
482    /// - 4 bytes: frame count (LE)
483    /// - Per frame:
484    ///   - 8 bytes: frame_number (LE)
485    ///   - 8 bytes: render_time_ns (LE)
486    ///   - 4 bytes: event_count (LE)
487    ///   - 1 byte: has_model_hash (0/1)
488    ///   - 8 bytes: model_hash (if has_model_hash)
489    ///   - 4 bytes: change_count (LE)
490    ///   - Per change: 20 bytes (CellChange)
491    pub fn export(&self, path: &Path) -> io::Result<()> {
492        let file = std::fs::File::create(path)?;
493        let mut w = std::io::BufWriter::new(file);
494
495        // Header
496        w.write_all(Self::MAGIC)?;
497
498        let (width, height) = if let Some(first) = self.snapshots.front() {
499            (first.width, first.height)
500        } else {
501            (0, 0)
502        };
503        w.write_all(&width.to_le_bytes())?;
504        w.write_all(&height.to_le_bytes())?;
505        w.write_all(&(self.snapshots.len() as u32).to_le_bytes())?;
506
507        // Frames
508        for (snapshot, meta) in self.snapshots.iter().zip(self.metadata.iter()) {
509            // Metadata
510            w.write_all(&meta.frame_number.to_le_bytes())?;
511            let render_ns = meta.render_time.as_nanos().min(u64::MAX as u128) as u64;
512            w.write_all(&render_ns.to_le_bytes())?;
513            w.write_all(&meta.event_count.to_le_bytes())?;
514
515            match meta.model_hash {
516                Some(h) => {
517                    w.write_all(&[1u8])?;
518                    w.write_all(&h.to_le_bytes())?;
519                }
520                None => {
521                    w.write_all(&[0u8])?;
522                    w.write_all(&0u64.to_le_bytes())?;
523                }
524            }
525
526            // Changes
527            w.write_all(&(snapshot.changes.len() as u32).to_le_bytes())?;
528            for change in &snapshot.changes {
529                change.write_to(&mut w)?;
530            }
531        }
532
533        w.flush()
534    }
535
536    /// Import recording from a file.
537    pub fn import(path: &Path) -> io::Result<Self> {
538        let file = std::fs::File::open(path)?;
539        let mut r = std::io::BufReader::new(file);
540
541        // Header
542        let mut magic = [0u8; 8];
543        r.read_exact(&mut magic)?;
544        if magic != *Self::MAGIC {
545            return Err(io::Error::new(
546                io::ErrorKind::InvalidData,
547                "Invalid file format (bad magic)",
548            ));
549        }
550
551        let mut buf2 = [0u8; 2];
552        let mut buf4 = [0u8; 4];
553        let mut buf8 = [0u8; 8];
554
555        r.read_exact(&mut buf2)?;
556        let width = u16::from_le_bytes(buf2);
557        r.read_exact(&mut buf2)?;
558        let height = u16::from_le_bytes(buf2);
559        r.read_exact(&mut buf4)?;
560        let frame_count = u32::from_le_bytes(buf4) as usize;
561
562        let mut tt = Self::new(frame_count.max(1));
563        tt.recording = false; // Don't auto-record during import
564
565        for _ in 0..frame_count {
566            // Metadata
567            r.read_exact(&mut buf8)?;
568            let frame_number = u64::from_le_bytes(buf8);
569            r.read_exact(&mut buf8)?;
570            let render_ns = u64::from_le_bytes(buf8);
571            r.read_exact(&mut buf4)?;
572            let event_count = u32::from_le_bytes(buf4);
573
574            let mut hash_flag = [0u8; 1];
575            r.read_exact(&mut hash_flag)?;
576            r.read_exact(&mut buf8)?;
577            let model_hash = if hash_flag[0] != 0 {
578                Some(u64::from_le_bytes(buf8))
579            } else {
580                None
581            };
582
583            let meta = FrameMetadata {
584                frame_number,
585                render_time: Duration::from_nanos(render_ns),
586                event_count,
587                model_hash,
588            };
589
590            // Changes
591            r.read_exact(&mut buf4)?;
592            let change_count = u32::from_le_bytes(buf4) as usize;
593            let mut changes = Vec::with_capacity(change_count);
594            for _ in 0..change_count {
595                changes.push(CellChange::read_from(&mut r)?);
596            }
597
598            let snapshot = CompressedFrame {
599                width,
600                height,
601                changes,
602                cursor: None,
603            };
604
605            tt.snapshots.push_back(snapshot);
606            tt.metadata.push_back(meta);
607        }
608
609        // Reconstruct last_buffer from final state
610        if !tt.snapshots.is_empty() && width > 0 && height > 0 {
611            let mut buf = Buffer::new(width, height);
612            for snapshot in &tt.snapshots {
613                snapshot.apply_to(&mut buf);
614            }
615            tt.last_buffer = Some(buf);
616        }
617
618        tt.frame_counter = frame_count as u64;
619        Ok(tt)
620    }
621}
622
623// ============================================================================
624// Tests
625// ============================================================================
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use ftui_render::cell::{CellAttrs, PackedRgba, StyleFlags};
631
632    fn make_metadata(n: u64) -> FrameMetadata {
633        FrameMetadata::new(n, Duration::from_millis(n + 1))
634    }
635
636    #[test]
637    fn new_time_travel() {
638        let tt = TimeTravel::new(100);
639        assert!(tt.is_empty());
640        assert_eq!(tt.len(), 0);
641        assert_eq!(tt.capacity(), 100);
642        assert!(tt.is_recording());
643    }
644
645    #[test]
646    #[should_panic(expected = "capacity must be > 0")]
647    fn zero_capacity_panics() {
648        TimeTravel::new(0);
649    }
650
651    #[test]
652    fn record_single_frame() {
653        let mut tt = TimeTravel::new(10);
654        let mut buf = Buffer::new(5, 3);
655        buf.set(0, 0, Cell::from_char('A'));
656        tt.record(&buf, make_metadata(0));
657
658        assert_eq!(tt.len(), 1);
659        assert_eq!(tt.frame_counter(), 1);
660    }
661
662    #[test]
663    fn record_multiple_frames() {
664        let mut tt = TimeTravel::new(10);
665        let mut buf = Buffer::new(5, 3);
666
667        for i in 0..5u64 {
668            buf.set(i as u16, 0, Cell::from_char(char::from(b'A' + i as u8)));
669            tt.record(&buf, make_metadata(i));
670        }
671
672        assert_eq!(tt.len(), 5);
673        assert_eq!(tt.frame_counter(), 5);
674    }
675
676    #[test]
677    fn capacity_eviction() {
678        let mut tt = TimeTravel::new(3);
679        let mut buf = Buffer::new(5, 1);
680
681        for i in 0..5u64 {
682            buf.set(i as u16 % 5, 0, Cell::from_char(char::from(b'A' + i as u8)));
683            tt.record(&buf, make_metadata(i));
684        }
685
686        // Only last 3 frames retained
687        assert_eq!(tt.len(), 3);
688        assert_eq!(tt.frame_counter(), 5);
689
690        // Oldest retained is frame 2
691        let meta = tt.metadata(0).unwrap();
692        assert_eq!(meta.frame_number, 2);
693    }
694
695    #[test]
696    fn eviction_preserves_data_integrity() {
697        let mut tt = TimeTravel::new(3);
698        let mut buf = Buffer::new(5, 1);
699
700        // Frame 0: A....
701        buf.set(0, 0, Cell::from_char('A'));
702        tt.record(&buf, make_metadata(0));
703
704        // Frame 1: AB... (Delta from 0)
705        buf.set(1, 0, Cell::from_char('B'));
706        tt.record(&buf, make_metadata(1));
707
708        // Frame 2: ABC.. (Delta from 1)
709        buf.set(2, 0, Cell::from_char('C'));
710        tt.record(&buf, make_metadata(2));
711
712        // State: [F0, D1, D2]
713        // Frame 3: ABCD. (Delta from 2) -> Evicts F0
714        buf.set(3, 0, Cell::from_char('D'));
715        tt.record(&buf, make_metadata(3));
716
717        // State should be: [F1, D2, D3] (conceptually)
718        // Verify we can reconstruct the new head (frame 1)
719        let f1 = tt.get(0).unwrap();
720        assert_eq!(f1.get(0, 0).unwrap().content.as_char(), Some('A'));
721        assert_eq!(f1.get(1, 0).unwrap().content.as_char(), Some('B'));
722        assert!(f1.get(2, 0).unwrap().is_empty());
723
724        // Verify we can reconstruct the tail (frame 3)
725        let f3 = tt.get(2).unwrap();
726        assert_eq!(f3.get(0, 0).unwrap().content.as_char(), Some('A'));
727        assert_eq!(f3.get(1, 0).unwrap().content.as_char(), Some('B'));
728        assert_eq!(f3.get(2, 0).unwrap().content.as_char(), Some('C'));
729        assert_eq!(f3.get(3, 0).unwrap().content.as_char(), Some('D'));
730    }
731
732    #[test]
733    fn get_reconstructs_frame() {
734        let mut tt = TimeTravel::new(10);
735        let mut buf = Buffer::new(5, 1);
736
737        // Frame 0: A____
738        buf.set(0, 0, Cell::from_char('A'));
739        tt.record(&buf, make_metadata(0));
740
741        // Frame 1: AB___
742        buf.set(1, 0, Cell::from_char('B'));
743        tt.record(&buf, make_metadata(1));
744
745        // Frame 2: ABC__
746        buf.set(2, 0, Cell::from_char('C'));
747        tt.record(&buf, make_metadata(2));
748
749        // Get frame 0
750        let f0 = tt.get(0).unwrap();
751        assert_eq!(f0.get(0, 0).unwrap().content.as_char(), Some('A'));
752        assert!(f0.get(1, 0).unwrap().is_empty());
753
754        // Get frame 1
755        let f1 = tt.get(1).unwrap();
756        assert_eq!(f1.get(0, 0).unwrap().content.as_char(), Some('A'));
757        assert_eq!(f1.get(1, 0).unwrap().content.as_char(), Some('B'));
758        assert!(f1.get(2, 0).unwrap().is_empty());
759
760        // Get frame 2
761        let f2 = tt.get(2).unwrap();
762        assert_eq!(f2.get(0, 0).unwrap().content.as_char(), Some('A'));
763        assert_eq!(f2.get(1, 0).unwrap().content.as_char(), Some('B'));
764        assert_eq!(f2.get(2, 0).unwrap().content.as_char(), Some('C'));
765    }
766
767    #[test]
768    fn get_out_of_range() {
769        let tt = TimeTravel::new(10);
770        assert!(tt.get(0).is_none());
771        assert!(tt.get(100).is_none());
772    }
773
774    #[test]
775    fn rewind_from_latest() {
776        let mut tt = TimeTravel::new(10);
777        let mut buf = Buffer::new(3, 1);
778
779        buf.set(0, 0, Cell::from_char('X'));
780        tt.record(&buf, make_metadata(0));
781
782        buf.set(1, 0, Cell::from_char('Y'));
783        tt.record(&buf, make_metadata(1));
784
785        buf.set(2, 0, Cell::from_char('Z'));
786        tt.record(&buf, make_metadata(2));
787
788        // rewind(0) = latest
789        let latest = tt.rewind(0).unwrap();
790        assert_eq!(latest.get(2, 0).unwrap().content.as_char(), Some('Z'));
791
792        // rewind(1) = one step back
793        let prev = tt.rewind(1).unwrap();
794        assert!(prev.get(2, 0).unwrap().is_empty());
795        assert_eq!(prev.get(1, 0).unwrap().content.as_char(), Some('Y'));
796
797        // rewind(2) = two steps back
798        let oldest = tt.rewind(2).unwrap();
799        assert!(oldest.get(1, 0).unwrap().is_empty());
800        assert_eq!(oldest.get(0, 0).unwrap().content.as_char(), Some('X'));
801
802        // rewind too far
803        assert!(tt.rewind(3).is_none());
804    }
805
806    #[test]
807    fn pause_resume_recording() {
808        let mut tt = TimeTravel::new(10);
809        let buf = Buffer::new(3, 1);
810
811        tt.record(&buf, make_metadata(0));
812        assert_eq!(tt.len(), 1);
813
814        tt.set_recording(false);
815        assert!(!tt.is_recording());
816        tt.record(&buf, make_metadata(1)); // Should be ignored
817        assert_eq!(tt.len(), 1);
818
819        tt.set_recording(true);
820        tt.record(&buf, make_metadata(2));
821        assert_eq!(tt.len(), 2);
822    }
823
824    #[test]
825    fn metadata_access() {
826        let mut tt = TimeTravel::new(10);
827        let buf = Buffer::new(3, 1);
828
829        let meta = FrameMetadata::new(42, Duration::from_millis(5))
830            .with_events(3)
831            .with_model_hash(0xDEAD);
832        tt.record(&buf, meta);
833
834        let stored = tt.metadata(0).unwrap();
835        assert_eq!(stored.frame_number, 42);
836        assert_eq!(stored.render_time, Duration::from_millis(5));
837        assert_eq!(stored.event_count, 3);
838        assert_eq!(stored.model_hash, Some(0xDEAD));
839    }
840
841    #[test]
842    fn latest_metadata() {
843        let mut tt = TimeTravel::new(10);
844        let buf = Buffer::new(3, 1);
845
846        assert!(tt.latest_metadata().is_none());
847
848        tt.record(&buf, make_metadata(0));
849        tt.record(&buf, make_metadata(1));
850
851        assert_eq!(tt.latest_metadata().unwrap().frame_number, 1);
852    }
853
854    #[test]
855    fn find_by_hash() {
856        let mut tt = TimeTravel::new(10);
857        let buf = Buffer::new(3, 1);
858
859        tt.record(
860            &buf,
861            FrameMetadata::new(0, Duration::ZERO).with_model_hash(100),
862        );
863        tt.record(
864            &buf,
865            FrameMetadata::new(1, Duration::ZERO).with_model_hash(200),
866        );
867        tt.record(
868            &buf,
869            FrameMetadata::new(2, Duration::ZERO).with_model_hash(300),
870        );
871
872        assert_eq!(tt.find_by_hash(200), Some(1));
873        assert_eq!(tt.find_by_hash(999), None);
874    }
875
876    #[test]
877    fn memory_usage_stays_bounded() {
878        let mut tt = TimeTravel::new(5);
879        let mut buf = Buffer::new(80, 24);
880
881        // Record many frames, but capacity is 5
882        for i in 0..100u64 {
883            buf.set((i % 80) as u16, (i % 24) as u16, Cell::from_char('#'));
884            tt.record(&buf, make_metadata(i));
885        }
886
887        assert_eq!(tt.len(), 5);
888        // Memory should be bounded (no unbounded growth)
889        let usage = tt.memory_usage();
890        assert!(usage < 1_000_000, "memory usage {usage} exceeds 1MB");
891    }
892
893    #[test]
894    fn clear_resets() {
895        let mut tt = TimeTravel::new(10);
896        let buf = Buffer::new(3, 1);
897        tt.record(&buf, make_metadata(0));
898        tt.record(&buf, make_metadata(1));
899
900        tt.clear();
901        assert!(tt.is_empty());
902        assert_eq!(tt.len(), 0);
903    }
904
905    #[test]
906    fn compressed_frame_full() {
907        let mut buf = Buffer::new(3, 2);
908        buf.set(0, 0, Cell::from_char('A'));
909        buf.set(2, 1, Cell::from_char('B'));
910
911        let cf = CompressedFrame::full(&buf);
912        assert_eq!(cf.change_count(), 2);
913
914        // Apply to empty buffer should reconstruct
915        let mut target = Buffer::new(3, 2);
916        cf.apply_to(&mut target);
917        assert_eq!(target.get(0, 0).unwrap().content.as_char(), Some('A'));
918        assert_eq!(target.get(2, 1).unwrap().content.as_char(), Some('B'));
919    }
920
921    #[test]
922    fn compressed_frame_delta() {
923        let mut buf1 = Buffer::new(5, 1);
924        buf1.set(0, 0, Cell::from_char('A'));
925        buf1.set(1, 0, Cell::from_char('B'));
926
927        let mut buf2 = Buffer::new(5, 1);
928        buf2.set(0, 0, Cell::from_char('A'));
929        buf2.set(1, 0, Cell::from_char('X')); // Changed
930        buf2.set(2, 0, Cell::from_char('C')); // Added
931
932        let cf = CompressedFrame::delta(&buf2, &buf1);
933        // Only cells that changed (B→X) and added (C) should be stored
934        assert_eq!(cf.change_count(), 2);
935
936        // Apply delta to buf1 should produce buf2
937        let mut result = buf1.clone();
938        cf.apply_to(&mut result);
939        assert_eq!(result.get(0, 0).unwrap().content.as_char(), Some('A'));
940        assert_eq!(result.get(1, 0).unwrap().content.as_char(), Some('X'));
941        assert_eq!(result.get(2, 0).unwrap().content.as_char(), Some('C'));
942    }
943
944    #[test]
945    fn compressed_frame_preserves_style() {
946        let mut buf = Buffer::new(3, 1);
947        let styled = Cell::from_char('S')
948            .with_fg(PackedRgba::rgb(255, 0, 0))
949            .with_bg(PackedRgba::rgb(0, 0, 255))
950            .with_attrs(CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 42));
951        buf.set_raw(0, 0, styled);
952
953        let cf = CompressedFrame::full(&buf);
954        let mut target = Buffer::new(3, 1);
955        cf.apply_to(&mut target);
956
957        let cell = target.get(0, 0).unwrap();
958        assert_eq!(cell.content.as_char(), Some('S'));
959        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
960        assert_eq!(cell.bg, PackedRgba::rgb(0, 0, 255));
961        assert!(cell.attrs.has_flag(StyleFlags::BOLD));
962        assert!(cell.attrs.has_flag(StyleFlags::ITALIC));
963        assert_eq!(cell.attrs.link_id(), 42);
964    }
965
966    #[test]
967    fn export_import_roundtrip() {
968        let dir = std::env::temp_dir().join("ftui_tt_test");
969        let _ = std::fs::remove_dir_all(&dir);
970        std::fs::create_dir_all(&dir).unwrap();
971        let path = dir.join("test.fttr");
972
973        // Create recording
974        let mut tt = TimeTravel::new(10);
975        let mut buf = Buffer::new(5, 2);
976
977        buf.set(0, 0, Cell::from_char('H'));
978        buf.set(1, 0, Cell::from_char('i'));
979        tt.record(
980            &buf,
981            FrameMetadata::new(0, Duration::from_millis(1)).with_events(2),
982        );
983
984        buf.set(0, 1, Cell::from_char('!'));
985        tt.record(
986            &buf,
987            FrameMetadata::new(1, Duration::from_millis(3))
988                .with_events(1)
989                .with_model_hash(0xCAFE),
990        );
991
992        // Export
993        tt.export(&path).unwrap();
994
995        // Import
996        let loaded = TimeTravel::import(&path).unwrap();
997        assert_eq!(loaded.len(), 2);
998
999        // Verify frame 0
1000        let f0 = loaded.get(0).unwrap();
1001        assert_eq!(f0.get(0, 0).unwrap().content.as_char(), Some('H'));
1002        assert_eq!(f0.get(1, 0).unwrap().content.as_char(), Some('i'));
1003        assert!(f0.get(0, 1).unwrap().is_empty());
1004
1005        // Verify frame 1
1006        let f1 = loaded.get(1).unwrap();
1007        assert_eq!(f1.get(0, 0).unwrap().content.as_char(), Some('H'));
1008        assert_eq!(f1.get(0, 1).unwrap().content.as_char(), Some('!'));
1009
1010        // Verify metadata
1011        let m0 = loaded.metadata(0).unwrap();
1012        assert_eq!(m0.frame_number, 0);
1013        assert_eq!(m0.render_time, Duration::from_millis(1));
1014        assert_eq!(m0.event_count, 2);
1015
1016        let m1 = loaded.metadata(1).unwrap();
1017        assert_eq!(m1.model_hash, Some(0xCAFE));
1018
1019        let _ = std::fs::remove_dir_all(&dir);
1020    }
1021
1022    #[test]
1023    fn import_invalid_magic() {
1024        let dir = std::env::temp_dir().join("ftui_tt_bad_magic");
1025        let _ = std::fs::remove_dir_all(&dir);
1026        std::fs::create_dir_all(&dir).unwrap();
1027        let path = dir.join("bad.fttr");
1028
1029        std::fs::write(&path, b"NOT-MAGIC").unwrap();
1030        let result = TimeTravel::import(&path);
1031        assert!(result.is_err());
1032
1033        let _ = std::fs::remove_dir_all(&dir);
1034    }
1035
1036    #[test]
1037    fn cell_change_serialization_roundtrip() {
1038        let change = CellChange {
1039            x: 42,
1040            y: 7,
1041            cell: Cell::from_char('Q')
1042                .with_fg(PackedRgba::rgb(10, 20, 30))
1043                .with_bg(PackedRgba::rgb(40, 50, 60))
1044                .with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, 999)),
1045        };
1046
1047        let mut bytes = Vec::new();
1048        change.write_to(&mut bytes).unwrap();
1049        assert_eq!(bytes.len(), CellChange::SERIALIZED_SIZE);
1050
1051        let mut cursor = std::io::Cursor::new(bytes);
1052        let restored = CellChange::read_from(&mut cursor).unwrap();
1053
1054        assert_eq!(restored.x, 42);
1055        assert_eq!(restored.y, 7);
1056        assert_eq!(restored.cell.content.as_char(), Some('Q'));
1057        assert_eq!(restored.cell.fg, PackedRgba::rgb(10, 20, 30));
1058        assert_eq!(restored.cell.bg, PackedRgba::rgb(40, 50, 60));
1059        assert!(restored.cell.attrs.has_flag(StyleFlags::UNDERLINE));
1060        assert_eq!(restored.cell.attrs.link_id(), 999);
1061    }
1062
1063    #[test]
1064    fn delta_encoding_efficiency() {
1065        let mut buf1 = Buffer::new(80, 24);
1066        for y in 0..24u16 {
1067            for x in 0..80u16 {
1068                buf1.set_raw(x, y, Cell::from_char('.'));
1069            }
1070        }
1071
1072        // Change only 5% of cells
1073        let mut buf2 = buf1.clone();
1074        for i in 0..96 {
1075            // 96 / 1920 ≈ 5%
1076            let x = (i * 7) % 80;
1077            let y = (i * 3) % 24;
1078            buf2.set_raw(x as u16, y as u16, Cell::from_char('#'));
1079        }
1080
1081        let full = CompressedFrame::full(&buf2);
1082        let delta = CompressedFrame::delta(&buf2, &buf1);
1083
1084        // Delta should be much smaller than full
1085        assert!(delta.change_count() < full.change_count());
1086        assert!(delta.memory_size() < full.memory_size());
1087    }
1088
1089    // ─── Edge-case tests (bd-3127i) ─────────────────────────────
1090
1091    #[test]
1092    fn frame_metadata_defaults() {
1093        let meta = FrameMetadata::new(5, Duration::from_millis(10));
1094        assert_eq!(meta.frame_number, 5);
1095        assert_eq!(meta.render_time, Duration::from_millis(10));
1096        assert_eq!(meta.event_count, 0);
1097        assert!(meta.model_hash.is_none());
1098    }
1099
1100    #[test]
1101    fn frame_metadata_builder_chain() {
1102        let meta = FrameMetadata::new(1, Duration::from_millis(2))
1103            .with_events(10)
1104            .with_model_hash(0xBEEF);
1105        assert_eq!(meta.frame_number, 1);
1106        assert_eq!(meta.event_count, 10);
1107        assert_eq!(meta.model_hash, Some(0xBEEF));
1108    }
1109
1110    #[test]
1111    fn frame_metadata_debug() {
1112        let meta = FrameMetadata::new(0, Duration::ZERO);
1113        let debug = format!("{meta:?}");
1114        assert!(debug.contains("FrameMetadata"));
1115    }
1116
1117    #[test]
1118    fn frame_metadata_clone() {
1119        let meta = FrameMetadata::new(7, Duration::from_millis(3)).with_model_hash(42);
1120        let cloned = meta.clone();
1121        assert_eq!(cloned.frame_number, 7);
1122        assert_eq!(cloned.model_hash, Some(42));
1123    }
1124
1125    #[test]
1126    fn compressed_frame_with_cursor() {
1127        let buf = Buffer::new(3, 1);
1128        let cf = CompressedFrame::full(&buf).with_cursor(Some((1, 0)));
1129        assert_eq!(cf.cursor, Some((1, 0)));
1130    }
1131
1132    #[test]
1133    fn compressed_frame_with_cursor_none() {
1134        let buf = Buffer::new(3, 1);
1135        let cf = CompressedFrame::full(&buf).with_cursor(None);
1136        assert_eq!(cf.cursor, None);
1137    }
1138
1139    #[test]
1140    fn compressed_frame_empty_buffer() {
1141        let buf = Buffer::new(5, 3);
1142        let cf = CompressedFrame::full(&buf);
1143        assert_eq!(cf.change_count(), 0, "empty buffer has no changes");
1144        assert_eq!(cf.width, 5);
1145        assert_eq!(cf.height, 3);
1146    }
1147
1148    #[test]
1149    fn compressed_frame_delta_identical_buffers() {
1150        let mut buf = Buffer::new(5, 3);
1151        buf.set(0, 0, Cell::from_char('X'));
1152        buf.set(2, 1, Cell::from_char('Y'));
1153
1154        let delta = CompressedFrame::delta(&buf, &buf);
1155        assert_eq!(delta.change_count(), 0, "identical buffers have no delta");
1156    }
1157
1158    #[test]
1159    fn compressed_frame_memory_size() {
1160        let mut buf = Buffer::new(5, 1);
1161        buf.set(0, 0, Cell::from_char('A'));
1162        buf.set(1, 0, Cell::from_char('B'));
1163
1164        let cf = CompressedFrame::full(&buf);
1165        assert_eq!(cf.change_count(), 2);
1166        let size = cf.memory_size();
1167        // Should be at least: struct size + 2 * sizeof(CellChange)
1168        assert!(
1169            size >= std::mem::size_of::<CompressedFrame>() + 2 * std::mem::size_of::<CellChange>()
1170        );
1171    }
1172
1173    #[test]
1174    fn compressed_frame_debug() {
1175        let buf = Buffer::new(3, 1);
1176        let cf = CompressedFrame::full(&buf);
1177        let debug = format!("{cf:?}");
1178        assert!(debug.contains("CompressedFrame"));
1179    }
1180
1181    #[test]
1182    fn compressed_frame_clone() {
1183        let mut buf = Buffer::new(3, 1);
1184        buf.set(0, 0, Cell::from_char('X'));
1185        let cf = CompressedFrame::full(&buf);
1186        let cloned = cf.clone();
1187        assert_eq!(cloned.change_count(), cf.change_count());
1188        assert_eq!(cloned.width, cf.width);
1189    }
1190
1191    #[test]
1192    fn cell_change_debug_clone_copy_eq() {
1193        let change = CellChange {
1194            x: 5,
1195            y: 3,
1196            cell: Cell::from_char('Q'),
1197        };
1198        // Debug
1199        let debug = format!("{change:?}");
1200        assert!(debug.contains("CellChange"));
1201
1202        // Clone + Copy
1203        let cloned = change;
1204        let copied = change; // Copy
1205        assert_eq!(change, cloned);
1206        assert_eq!(change, copied);
1207
1208        // PartialEq with different
1209        let other = CellChange {
1210            x: 5,
1211            y: 3,
1212            cell: Cell::from_char('R'),
1213        };
1214        assert_ne!(change, other);
1215    }
1216
1217    #[test]
1218    fn time_travel_debug() {
1219        let tt = TimeTravel::new(10);
1220        let debug = format!("{tt:?}");
1221        assert!(debug.contains("TimeTravel"));
1222    }
1223
1224    #[test]
1225    fn capacity_one() {
1226        let mut tt = TimeTravel::new(1);
1227        let mut buf = Buffer::new(3, 1);
1228
1229        buf.set(0, 0, Cell::from_char('A'));
1230        tt.record(&buf, make_metadata(0));
1231        assert_eq!(tt.len(), 1);
1232
1233        buf.set(1, 0, Cell::from_char('B'));
1234        tt.record(&buf, make_metadata(1));
1235        assert_eq!(tt.len(), 1, "capacity 1 should keep only latest");
1236        assert_eq!(tt.frame_counter(), 2);
1237
1238        // The retained frame should be the latest
1239        let latest = tt.rewind(0).unwrap();
1240        assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('A'));
1241        assert_eq!(latest.get(1, 0).unwrap().content.as_char(), Some('B'));
1242    }
1243
1244    #[test]
1245    fn clear_preserves_capacity() {
1246        let mut tt = TimeTravel::new(50);
1247        let buf = Buffer::new(3, 1);
1248        tt.record(&buf, make_metadata(0));
1249
1250        tt.clear();
1251        assert_eq!(tt.capacity(), 50);
1252        assert!(tt.is_empty());
1253    }
1254
1255    #[test]
1256    fn clear_then_record() {
1257        let mut tt = TimeTravel::new(10);
1258        let mut buf = Buffer::new(3, 1);
1259
1260        buf.set(0, 0, Cell::from_char('A'));
1261        tt.record(&buf, make_metadata(0));
1262        tt.clear();
1263
1264        buf.set(0, 0, Cell::from_char('B'));
1265        tt.record(&buf, make_metadata(1));
1266        assert_eq!(tt.len(), 1);
1267
1268        let frame = tt.rewind(0).unwrap();
1269        assert_eq!(frame.get(0, 0).unwrap().content.as_char(), Some('B'));
1270    }
1271
1272    #[test]
1273    fn record_different_buffer_sizes_accepted() {
1274        let mut tt = TimeTravel::new(10);
1275
1276        let mut buf1 = Buffer::new(5, 3);
1277        buf1.set(0, 0, Cell::from_char('A'));
1278        tt.record(&buf1, make_metadata(0));
1279
1280        // Different size buffer → should trigger full snapshot (not panic)
1281        let mut buf2 = Buffer::new(10, 5);
1282        buf2.set(0, 0, Cell::from_char('B'));
1283        tt.record(&buf2, make_metadata(1));
1284
1285        assert_eq!(tt.len(), 2);
1286        assert_eq!(tt.frame_counter(), 2);
1287
1288        // First frame (same-size) is still accessible
1289        let frame0 = tt.get(0).unwrap();
1290        assert_eq!(frame0.get(0, 0).unwrap().content.as_char(), Some('A'));
1291    }
1292
1293    #[test]
1294    fn frame_counter_cumulative_through_eviction() {
1295        let mut tt = TimeTravel::new(2);
1296        let buf = Buffer::new(3, 1);
1297
1298        for i in 0..10u64 {
1299            tt.record(&buf, make_metadata(i));
1300        }
1301        assert_eq!(tt.frame_counter(), 10);
1302        assert_eq!(tt.len(), 2);
1303    }
1304
1305    #[test]
1306    fn frame_counter_does_not_reset_on_clear() {
1307        let mut tt = TimeTravel::new(10);
1308        let buf = Buffer::new(3, 1);
1309        tt.record(&buf, make_metadata(0));
1310        tt.record(&buf, make_metadata(1));
1311        assert_eq!(tt.frame_counter(), 2);
1312
1313        tt.clear();
1314        // frame_counter should still be 2 (it's cumulative)
1315        assert_eq!(tt.frame_counter(), 2);
1316    }
1317
1318    #[test]
1319    fn find_by_hash_returns_first_match() {
1320        let mut tt = TimeTravel::new(10);
1321        let buf = Buffer::new(3, 1);
1322
1323        tt.record(
1324            &buf,
1325            FrameMetadata::new(0, Duration::ZERO).with_model_hash(42),
1326        );
1327        tt.record(
1328            &buf,
1329            FrameMetadata::new(1, Duration::ZERO).with_model_hash(42),
1330        );
1331
1332        // Should return the first occurrence
1333        assert_eq!(tt.find_by_hash(42), Some(0));
1334    }
1335
1336    #[test]
1337    fn find_by_hash_no_hashes() {
1338        let mut tt = TimeTravel::new(10);
1339        let buf = Buffer::new(3, 1);
1340
1341        tt.record(&buf, FrameMetadata::new(0, Duration::ZERO));
1342        tt.record(&buf, FrameMetadata::new(1, Duration::ZERO));
1343
1344        assert_eq!(tt.find_by_hash(0), None);
1345        assert_eq!(tt.find_by_hash(42), None);
1346    }
1347
1348    #[test]
1349    fn metadata_out_of_range() {
1350        let mut tt = TimeTravel::new(10);
1351        let buf = Buffer::new(3, 1);
1352        tt.record(&buf, make_metadata(0));
1353
1354        assert!(tt.metadata(0).is_some());
1355        assert!(tt.metadata(1).is_none());
1356        assert!(tt.metadata(999).is_none());
1357    }
1358
1359    #[test]
1360    fn latest_metadata_after_eviction() {
1361        let mut tt = TimeTravel::new(2);
1362        let buf = Buffer::new(3, 1);
1363
1364        tt.record(&buf, make_metadata(0));
1365        tt.record(&buf, make_metadata(1));
1366        tt.record(&buf, make_metadata(2));
1367
1368        assert_eq!(tt.latest_metadata().unwrap().frame_number, 2);
1369    }
1370
1371    #[test]
1372    fn rewind_after_eviction() {
1373        let mut tt = TimeTravel::new(3);
1374        let mut buf = Buffer::new(5, 1);
1375
1376        for i in 0..6u64 {
1377            buf.set(i as u16 % 5, 0, Cell::from_char(char::from(b'A' + i as u8)));
1378            tt.record(&buf, make_metadata(i));
1379        }
1380
1381        // Only frames 3, 4, 5 retained
1382        assert_eq!(tt.len(), 3);
1383
1384        // rewind(0) should be latest (frame 5)
1385        let latest = tt.rewind(0).unwrap();
1386        // Frame 5 should have A, B, C, D, F (E at index 0 was overwritten by F)
1387        assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('F'));
1388
1389        // rewind too far
1390        assert!(tt.rewind(3).is_none());
1391    }
1392
1393    #[test]
1394    fn multiple_eviction_cycles() {
1395        let mut tt = TimeTravel::new(3);
1396        let mut buf = Buffer::new(3, 1);
1397
1398        // Record 20 frames, each with a unique character at position 0
1399        for i in 0..20u64 {
1400            let ch = char::from(b'A' + (i % 26) as u8);
1401            buf.set(0, 0, Cell::from_char(ch));
1402            tt.record(&buf, make_metadata(i));
1403        }
1404
1405        assert_eq!(tt.len(), 3);
1406        assert_eq!(tt.frame_counter(), 20);
1407
1408        // Latest frame should have 'T' (index 19 % 26 = 19 → 'T')
1409        let latest = tt.rewind(0).unwrap();
1410        assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('T'));
1411    }
1412
1413    #[test]
1414    fn record_when_paused_does_not_increment_counter() {
1415        let mut tt = TimeTravel::new(10);
1416        let buf = Buffer::new(3, 1);
1417
1418        tt.set_recording(false);
1419        tt.record(&buf, make_metadata(0));
1420        assert_eq!(tt.frame_counter(), 0);
1421        assert!(tt.is_empty());
1422    }
1423
1424    #[test]
1425    fn is_empty_after_record() {
1426        let mut tt = TimeTravel::new(10);
1427        assert!(tt.is_empty());
1428
1429        let buf = Buffer::new(3, 1);
1430        tt.record(&buf, make_metadata(0));
1431        assert!(!tt.is_empty());
1432    }
1433
1434    #[test]
1435    fn export_empty_recording() {
1436        let dir = std::env::temp_dir().join("ftui_tt_empty_export");
1437        let _ = std::fs::remove_dir_all(&dir);
1438        std::fs::create_dir_all(&dir).unwrap();
1439        let path = dir.join("empty.fttr");
1440
1441        let tt = TimeTravel::new(10);
1442        tt.export(&path).unwrap();
1443
1444        let loaded = TimeTravel::import(&path).unwrap();
1445        assert!(loaded.is_empty());
1446        assert_eq!(loaded.len(), 0);
1447
1448        let _ = std::fs::remove_dir_all(&dir);
1449    }
1450
1451    #[test]
1452    fn export_import_preserves_styles() {
1453        let dir = std::env::temp_dir().join("ftui_tt_style_rt");
1454        let _ = std::fs::remove_dir_all(&dir);
1455        std::fs::create_dir_all(&dir).unwrap();
1456        let path = dir.join("styled.fttr");
1457
1458        let mut tt = TimeTravel::new(10);
1459        let mut buf = Buffer::new(3, 1);
1460        let styled = Cell::from_char('Z')
1461            .with_fg(PackedRgba::rgb(100, 200, 50))
1462            .with_bg(PackedRgba::rgb(10, 20, 30))
1463            .with_attrs(CellAttrs::new(StyleFlags::BOLD | StyleFlags::UNDERLINE, 7));
1464        buf.set_raw(0, 0, styled);
1465        tt.record(&buf, make_metadata(0));
1466
1467        tt.export(&path).unwrap();
1468        let loaded = TimeTravel::import(&path).unwrap();
1469
1470        let frame = loaded.get(0).unwrap();
1471        let cell = frame.get(0, 0).unwrap();
1472        assert_eq!(cell.content.as_char(), Some('Z'));
1473        assert_eq!(cell.fg, PackedRgba::rgb(100, 200, 50));
1474        assert_eq!(cell.bg, PackedRgba::rgb(10, 20, 30));
1475        assert!(cell.attrs.has_flag(StyleFlags::BOLD));
1476        assert!(cell.attrs.has_flag(StyleFlags::UNDERLINE));
1477        assert_eq!(cell.attrs.link_id(), 7);
1478
1479        let _ = std::fs::remove_dir_all(&dir);
1480    }
1481
1482    #[test]
1483    fn export_import_no_model_hash() {
1484        let dir = std::env::temp_dir().join("ftui_tt_no_hash");
1485        let _ = std::fs::remove_dir_all(&dir);
1486        std::fs::create_dir_all(&dir).unwrap();
1487        let path = dir.join("no_hash.fttr");
1488
1489        let mut tt = TimeTravel::new(10);
1490        let buf = Buffer::new(3, 1);
1491        tt.record(&buf, FrameMetadata::new(0, Duration::from_millis(5)));
1492
1493        tt.export(&path).unwrap();
1494        let loaded = TimeTravel::import(&path).unwrap();
1495
1496        assert!(loaded.metadata(0).unwrap().model_hash.is_none());
1497
1498        let _ = std::fs::remove_dir_all(&dir);
1499    }
1500
1501    #[test]
1502    fn memory_usage_positive() {
1503        let tt = TimeTravel::new(10);
1504        assert!(
1505            tt.memory_usage() > 0,
1506            "empty recorder should still have base size"
1507        );
1508    }
1509
1510    #[test]
1511    fn memory_usage_grows_with_frames() {
1512        let mut tt = TimeTravel::new(100);
1513        let base = tt.memory_usage();
1514
1515        let mut buf = Buffer::new(10, 5);
1516        buf.set(0, 0, Cell::from_char('A'));
1517        tt.record(&buf, make_metadata(0));
1518
1519        let after_one = tt.memory_usage();
1520        assert!(after_one > base, "memory should grow after recording");
1521    }
1522
1523    #[test]
1524    fn get_all_frames_after_eviction() {
1525        let mut tt = TimeTravel::new(3);
1526        let mut buf = Buffer::new(3, 1);
1527
1528        // Record 5 frames: A, B, C, D, E
1529        for i in 0..5u64 {
1530            buf.set(0, 0, Cell::from_char(char::from(b'A' + i as u8)));
1531            tt.record(&buf, make_metadata(i));
1532        }
1533
1534        // Only 3 retained: frames 2(C), 3(D), 4(E)
1535        assert_eq!(tt.len(), 3);
1536
1537        // All 3 frames should be retrievable
1538        for i in 0..3 {
1539            assert!(tt.get(i).is_some(), "frame {i} should be retrievable");
1540        }
1541        assert!(tt.get(3).is_none());
1542    }
1543
1544    #[test]
1545    fn cell_change_serialized_size() {
1546        assert_eq!(CellChange::SERIALIZED_SIZE, 20);
1547    }
1548
1549    #[test]
1550    fn compressed_frame_all_cells_changed() {
1551        let mut buf1 = Buffer::new(3, 2);
1552        for y in 0..2u16 {
1553            for x in 0..3u16 {
1554                buf1.set(x, y, Cell::from_char('.'));
1555            }
1556        }
1557
1558        let mut buf2 = Buffer::new(3, 2);
1559        for y in 0..2u16 {
1560            for x in 0..3u16 {
1561                buf2.set(x, y, Cell::from_char('#'));
1562            }
1563        }
1564
1565        let delta = CompressedFrame::delta(&buf2, &buf1);
1566        // All 6 cells changed
1567        assert_eq!(delta.change_count(), 6);
1568    }
1569
1570    // ─── Additional edge-case tests (bd-3127i phase 2) ──────────
1571
1572    #[test]
1573    fn cell_change_roundtrip_empty_content() {
1574        use ftui_render::cell::CellContent;
1575
1576        let change = CellChange {
1577            x: 0,
1578            y: 0,
1579            cell: Cell {
1580                content: CellContent::EMPTY,
1581                fg: PackedRgba::TRANSPARENT,
1582                bg: PackedRgba::TRANSPARENT,
1583                attrs: CellAttrs::NONE,
1584            },
1585        };
1586
1587        let mut bytes = Vec::new();
1588        change.write_to(&mut bytes).unwrap();
1589        assert_eq!(bytes.len(), CellChange::SERIALIZED_SIZE);
1590
1591        let mut cursor = std::io::Cursor::new(bytes);
1592        let restored = CellChange::read_from(&mut cursor).unwrap();
1593        assert!(restored.cell.content.is_empty());
1594    }
1595
1596    #[test]
1597    fn cell_change_roundtrip_continuation_content() {
1598        use ftui_render::cell::CellContent;
1599
1600        let change = CellChange {
1601            x: 10,
1602            y: 5,
1603            cell: Cell {
1604                content: CellContent::CONTINUATION,
1605                fg: PackedRgba::WHITE,
1606                bg: PackedRgba::TRANSPARENT,
1607                attrs: CellAttrs::NONE,
1608            },
1609        };
1610
1611        let mut bytes = Vec::new();
1612        change.write_to(&mut bytes).unwrap();
1613
1614        let mut cursor = std::io::Cursor::new(bytes);
1615        let restored = CellChange::read_from(&mut cursor).unwrap();
1616        assert!(restored.cell.content.is_continuation());
1617        assert_eq!(restored.x, 10);
1618        assert_eq!(restored.y, 5);
1619    }
1620
1621    #[test]
1622    fn cell_change_eq_reflexive_and_symmetric() {
1623        let a = CellChange {
1624            x: 1,
1625            y: 2,
1626            cell: Cell::from_char('A'),
1627        };
1628        let b = CellChange {
1629            x: 1,
1630            y: 2,
1631            cell: Cell::from_char('A'),
1632        };
1633        assert_eq!(a, a); // reflexive
1634        assert_eq!(a, b); // symmetric
1635        assert_eq!(b, a);
1636    }
1637
1638    #[test]
1639    fn cell_change_ne_different_position() {
1640        let a = CellChange {
1641            x: 0,
1642            y: 0,
1643            cell: Cell::from_char('A'),
1644        };
1645        let b = CellChange {
1646            x: 1,
1647            y: 0,
1648            cell: Cell::from_char('A'),
1649        };
1650        assert_ne!(a, b);
1651    }
1652
1653    #[test]
1654    fn capacity_one_with_styled_cells() {
1655        let mut tt = TimeTravel::new(1);
1656        let mut buf = Buffer::new(3, 1);
1657
1658        let styled = Cell::from_char('S')
1659            .with_fg(PackedRgba::rgb(255, 0, 0))
1660            .with_bg(PackedRgba::rgb(0, 255, 0))
1661            .with_attrs(CellAttrs::new(StyleFlags::BOLD, 100));
1662        buf.set_raw(0, 0, styled);
1663        tt.record(&buf, make_metadata(0));
1664
1665        buf.set_raw(1, 0, Cell::from_char('T'));
1666        tt.record(&buf, make_metadata(1));
1667
1668        assert_eq!(tt.len(), 1);
1669
1670        let latest = tt.rewind(0).unwrap();
1671        let cell = latest.get(0, 0).unwrap();
1672        assert_eq!(cell.content.as_char(), Some('S'));
1673        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1674        assert!(cell.attrs.has_flag(StyleFlags::BOLD));
1675        assert_eq!(cell.attrs.link_id(), 100);
1676    }
1677
1678    #[test]
1679    fn multiple_clear_record_cycles() {
1680        let mut tt = TimeTravel::new(10);
1681        let mut buf = Buffer::new(3, 1);
1682
1683        for cycle in 0..5u64 {
1684            buf.set(0, 0, Cell::from_char(char::from(b'A' + cycle as u8)));
1685            tt.record(&buf, make_metadata(cycle));
1686            tt.clear();
1687            assert!(tt.is_empty());
1688        }
1689
1690        // After final clear, record one more
1691        buf.set(0, 0, Cell::from_char('Z'));
1692        tt.record(&buf, make_metadata(99));
1693        assert_eq!(tt.len(), 1);
1694
1695        let frame = tt.rewind(0).unwrap();
1696        assert_eq!(frame.get(0, 0).unwrap().content.as_char(), Some('Z'));
1697    }
1698
1699    #[test]
1700    fn memory_usage_decreases_after_clear() {
1701        let mut tt = TimeTravel::new(100);
1702        let mut buf = Buffer::new(20, 10);
1703
1704        for i in 0..50u64 {
1705            buf.set((i % 20) as u16, (i % 10) as u16, Cell::from_char('#'));
1706            tt.record(&buf, make_metadata(i));
1707        }
1708
1709        let usage_before_clear = tt.memory_usage();
1710        tt.clear();
1711        let usage_after_clear = tt.memory_usage();
1712
1713        assert!(
1714            usage_after_clear < usage_before_clear,
1715            "memory should decrease after clear: {usage_after_clear} >= {usage_before_clear}"
1716        );
1717    }
1718
1719    #[test]
1720    fn minimal_1x1_buffer_record_and_rewind() {
1721        let mut tt = TimeTravel::new(5);
1722        let mut buf = Buffer::new(1, 1);
1723
1724        buf.set(0, 0, Cell::from_char('X'));
1725        tt.record(&buf, make_metadata(0));
1726
1727        buf.set(0, 0, Cell::from_char('Y'));
1728        tt.record(&buf, make_metadata(1));
1729
1730        let f0 = tt.rewind(1).unwrap();
1731        assert_eq!(f0.get(0, 0).unwrap().content.as_char(), Some('X'));
1732
1733        let f1 = tt.rewind(0).unwrap();
1734        assert_eq!(f1.get(0, 0).unwrap().content.as_char(), Some('Y'));
1735    }
1736
1737    #[test]
1738    fn export_import_after_evictions() {
1739        let dir = std::env::temp_dir().join("ftui_tt_eviction_export");
1740        let _ = std::fs::remove_dir_all(&dir);
1741        std::fs::create_dir_all(&dir).unwrap();
1742        let path = dir.join("evicted.fttr");
1743
1744        let mut tt = TimeTravel::new(3);
1745        let mut buf = Buffer::new(5, 1);
1746
1747        // Record 6 frames so that 3 evictions occur (capacity=3)
1748        for i in 0..6u64 {
1749            buf.set(0, 0, Cell::from_char(char::from(b'A' + i as u8)));
1750            tt.record(&buf, make_metadata(i));
1751        }
1752        assert_eq!(tt.len(), 3);
1753
1754        // Export after eviction
1755        tt.export(&path).unwrap();
1756
1757        // Import and verify data integrity
1758        let loaded = TimeTravel::import(&path).unwrap();
1759        assert_eq!(loaded.len(), 3);
1760
1761        // Verify latest frame (frame 5 → 'F')
1762        let latest = loaded.get(2).unwrap();
1763        assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('F'));
1764
1765        // Verify oldest retained (frame 3 → 'D')
1766        let oldest = loaded.get(0).unwrap();
1767        assert_eq!(oldest.get(0, 0).unwrap().content.as_char(), Some('D'));
1768
1769        // Verify metadata of oldest retained
1770        assert_eq!(loaded.metadata(0).unwrap().frame_number, 3);
1771
1772        let _ = std::fs::remove_dir_all(&dir);
1773    }
1774
1775    #[test]
1776    fn export_import_capacity_one() {
1777        let dir = std::env::temp_dir().join("ftui_tt_cap1_export");
1778        let _ = std::fs::remove_dir_all(&dir);
1779        std::fs::create_dir_all(&dir).unwrap();
1780        let path = dir.join("cap1.fttr");
1781
1782        let mut tt = TimeTravel::new(1);
1783        let mut buf = Buffer::new(3, 1);
1784
1785        buf.set(0, 0, Cell::from_char('A'));
1786        tt.record(&buf, make_metadata(0));
1787
1788        buf.set(1, 0, Cell::from_char('B'));
1789        tt.record(&buf, make_metadata(1)); // Evicts frame 0
1790
1791        tt.export(&path).unwrap();
1792        let loaded = TimeTravel::import(&path).unwrap();
1793        assert_eq!(loaded.len(), 1);
1794
1795        let frame = loaded.get(0).unwrap();
1796        assert_eq!(frame.get(0, 0).unwrap().content.as_char(), Some('A'));
1797        assert_eq!(frame.get(1, 0).unwrap().content.as_char(), Some('B'));
1798
1799        let _ = std::fs::remove_dir_all(&dir);
1800    }
1801
1802    #[test]
1803    fn size_change_triggers_full_snapshot() {
1804        let mut tt = TimeTravel::new(10);
1805
1806        // Frame 0: 5x1 with 'A'
1807        let mut buf1 = Buffer::new(5, 1);
1808        buf1.set(0, 0, Cell::from_char('A'));
1809        tt.record(&buf1, make_metadata(0));
1810
1811        // Frame 1: 3x2 with 'B' (different dimensions)
1812        let mut buf2 = Buffer::new(3, 2);
1813        buf2.set(0, 0, Cell::from_char('B'));
1814        tt.record(&buf2, make_metadata(1));
1815
1816        // Frame 0 should still reconstruct with its own dims
1817        let f0 = tt.get(0).unwrap();
1818        assert_eq!(f0.width(), 5);
1819        assert_eq!(f0.height(), 1);
1820        assert_eq!(f0.get(0, 0).unwrap().content.as_char(), Some('A'));
1821
1822        // The snapshot count should reflect both frames
1823        assert_eq!(tt.len(), 2);
1824    }
1825
1826    #[test]
1827    fn compressed_frame_full_all_non_default() {
1828        let mut buf = Buffer::new(4, 3);
1829        for y in 0..3u16 {
1830            for x in 0..4u16 {
1831                buf.set(x, y, Cell::from_char('#'));
1832            }
1833        }
1834
1835        let cf = CompressedFrame::full(&buf);
1836        assert_eq!(cf.change_count(), 12, "all 12 cells are non-default");
1837
1838        let mut target = Buffer::new(4, 3);
1839        cf.apply_to(&mut target);
1840        for y in 0..3u16 {
1841            for x in 0..4u16 {
1842                assert_eq!(target.get(x, y).unwrap().content.as_char(), Some('#'));
1843            }
1844        }
1845    }
1846
1847    #[test]
1848    fn compressed_frame_cursor_preserved_through_clone() {
1849        let buf = Buffer::new(3, 1);
1850        let cf = CompressedFrame::full(&buf).with_cursor(Some((2, 0)));
1851        let cloned = cf.clone();
1852        assert_eq!(cloned.cursor, Some((2, 0)));
1853    }
1854
1855    #[test]
1856    fn eviction_rebase_preserves_all_retained_frames() {
1857        // Exercise the eviction rebase logic (lines 329-345) thoroughly:
1858        // Record capacity+2 frames, verify all retained frames reconstruct correctly.
1859        let mut tt = TimeTravel::new(4);
1860        let mut buf = Buffer::new(5, 1);
1861
1862        // Frame 0: A____
1863        buf.set(0, 0, Cell::from_char('A'));
1864        tt.record(&buf, make_metadata(0));
1865        // Frame 1: AB___
1866        buf.set(1, 0, Cell::from_char('B'));
1867        tt.record(&buf, make_metadata(1));
1868        // Frame 2: ABC__
1869        buf.set(2, 0, Cell::from_char('C'));
1870        tt.record(&buf, make_metadata(2));
1871        // Frame 3: ABCD_
1872        buf.set(3, 0, Cell::from_char('D'));
1873        tt.record(&buf, make_metadata(3));
1874        // Frame 4: ABCDE → evicts frame 0, rebases frame 1
1875        buf.set(4, 0, Cell::from_char('E'));
1876        tt.record(&buf, make_metadata(4));
1877        // Frame 5: XBCDE → evicts frame 1 (rebased), rebases frame 2
1878        buf.set(0, 0, Cell::from_char('X'));
1879        tt.record(&buf, make_metadata(5));
1880
1881        assert_eq!(tt.len(), 4);
1882
1883        // Retained: frames 2, 3, 4, 5
1884        let f2 = tt.get(0).unwrap();
1885        assert_eq!(f2.get(0, 0).unwrap().content.as_char(), Some('A'));
1886        assert_eq!(f2.get(1, 0).unwrap().content.as_char(), Some('B'));
1887        assert_eq!(f2.get(2, 0).unwrap().content.as_char(), Some('C'));
1888        assert!(f2.get(3, 0).unwrap().is_empty());
1889
1890        let f5 = tt.get(3).unwrap();
1891        assert_eq!(f5.get(0, 0).unwrap().content.as_char(), Some('X'));
1892        assert_eq!(f5.get(1, 0).unwrap().content.as_char(), Some('B'));
1893        assert_eq!(f5.get(4, 0).unwrap().content.as_char(), Some('E'));
1894    }
1895
1896    #[test]
1897    fn pause_does_not_affect_existing_frames() {
1898        let mut tt = TimeTravel::new(10);
1899        let mut buf = Buffer::new(3, 1);
1900
1901        buf.set(0, 0, Cell::from_char('A'));
1902        tt.record(&buf, make_metadata(0));
1903
1904        tt.set_recording(false);
1905
1906        // Existing frame should still be accessible
1907        let frame = tt.rewind(0).unwrap();
1908        assert_eq!(frame.get(0, 0).unwrap().content.as_char(), Some('A'));
1909    }
1910
1911    #[test]
1912    fn find_by_hash_after_eviction() {
1913        let mut tt = TimeTravel::new(2);
1914        let buf = Buffer::new(3, 1);
1915
1916        // Hash 100 will be evicted
1917        tt.record(
1918            &buf,
1919            FrameMetadata::new(0, Duration::ZERO).with_model_hash(100),
1920        );
1921        tt.record(
1922            &buf,
1923            FrameMetadata::new(1, Duration::ZERO).with_model_hash(200),
1924        );
1925        tt.record(
1926            &buf,
1927            FrameMetadata::new(2, Duration::ZERO).with_model_hash(300),
1928        );
1929
1930        // Hash 100 was evicted
1931        assert_eq!(tt.find_by_hash(100), None);
1932        // Hash 200 is now at index 0
1933        assert_eq!(tt.find_by_hash(200), Some(0));
1934        // Hash 300 is at index 1
1935        assert_eq!(tt.find_by_hash(300), Some(1));
1936    }
1937
1938    #[test]
1939    fn frame_metadata_zero_duration() {
1940        let meta = FrameMetadata::new(0, Duration::ZERO);
1941        assert_eq!(meta.render_time, Duration::ZERO);
1942        assert_eq!(meta.frame_number, 0);
1943    }
1944
1945    #[test]
1946    fn frame_metadata_large_values() {
1947        let meta = FrameMetadata::new(u64::MAX, Duration::from_secs(3600))
1948            .with_events(u32::MAX)
1949            .with_model_hash(u64::MAX);
1950        assert_eq!(meta.frame_number, u64::MAX);
1951        assert_eq!(meta.event_count, u32::MAX);
1952        assert_eq!(meta.model_hash, Some(u64::MAX));
1953    }
1954
1955    #[test]
1956    fn export_import_large_values_roundtrip() {
1957        let dir = std::env::temp_dir().join("ftui_tt_large_vals");
1958        let _ = std::fs::remove_dir_all(&dir);
1959        std::fs::create_dir_all(&dir).unwrap();
1960        let path = dir.join("large.fttr");
1961
1962        let mut tt = TimeTravel::new(10);
1963        let buf = Buffer::new(3, 1);
1964        let meta = FrameMetadata::new(u64::MAX, Duration::from_secs(3600))
1965            .with_events(u32::MAX)
1966            .with_model_hash(u64::MAX);
1967        tt.record(&buf, meta);
1968
1969        tt.export(&path).unwrap();
1970        let loaded = TimeTravel::import(&path).unwrap();
1971
1972        let m = loaded.metadata(0).unwrap();
1973        assert_eq!(m.frame_number, u64::MAX);
1974        assert_eq!(m.event_count, u32::MAX);
1975        assert_eq!(m.model_hash, Some(u64::MAX));
1976
1977        let _ = std::fs::remove_dir_all(&dir);
1978    }
1979
1980    #[test]
1981    fn cell_change_max_coordinates() {
1982        let change = CellChange {
1983            x: u16::MAX,
1984            y: u16::MAX,
1985            cell: Cell::from_char('Z'),
1986        };
1987
1988        let mut bytes = Vec::new();
1989        change.write_to(&mut bytes).unwrap();
1990
1991        let mut cursor = std::io::Cursor::new(bytes);
1992        let restored = CellChange::read_from(&mut cursor).unwrap();
1993        assert_eq!(restored.x, u16::MAX);
1994        assert_eq!(restored.y, u16::MAX);
1995        assert_eq!(restored.cell.content.as_char(), Some('Z'));
1996    }
1997
1998    #[test]
1999    fn compressed_frame_delta_single_cell_change() {
2000        let mut buf1 = Buffer::new(10, 10);
2001        buf1.set(5, 5, Cell::from_char('O'));
2002
2003        let mut buf2 = buf1.clone();
2004        buf2.set(5, 5, Cell::from_char('X'));
2005
2006        let delta = CompressedFrame::delta(&buf2, &buf1);
2007        assert_eq!(delta.change_count(), 1);
2008
2009        let mut result = buf1.clone();
2010        delta.apply_to(&mut result);
2011        assert_eq!(result.get(5, 5).unwrap().content.as_char(), Some('X'));
2012    }
2013}