1#![forbid(unsafe_code)]
2
3use 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#[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 #[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 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 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#[derive(Debug, Clone)]
141pub struct CompressedFrame {
142 width: u16,
144 height: u16,
145 changes: Vec<CellChange>,
147 cursor: Option<(u16, u16)>,
149}
150
151impl CompressedFrame {
152 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 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 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 pub fn change_count(&self) -> usize {
213 self.changes.len()
214 }
215
216 pub fn memory_size(&self) -> usize {
218 std::mem::size_of::<Self>() + self.changes.len() * std::mem::size_of::<CellChange>()
219 }
220
221 #[must_use]
223 pub fn with_cursor(mut self, cursor: Option<(u16, u16)>) -> Self {
224 self.cursor = cursor;
225 self
226 }
227}
228
229#[derive(Debug, Clone)]
235pub struct FrameMetadata {
236 pub frame_number: u64,
238 pub render_time: Duration,
240 pub event_count: u32,
242 pub model_hash: Option<u64>,
244}
245
246impl FrameMetadata {
247 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 #[must_use]
259 pub fn with_events(mut self, count: u32) -> Self {
260 self.event_count = count;
261 self
262 }
263
264 #[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#[derive(Debug)]
287pub struct TimeTravel {
288 snapshots: VecDeque<CompressedFrame>,
290 metadata: VecDeque<FrameMetadata>,
292 capacity: usize,
294 frame_counter: u64,
296 recording: bool,
298 last_buffer: Option<Buffer>,
300}
301
302impl TimeTravel {
303 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 pub fn record(&mut self, buf: &Buffer, metadata: FrameMetadata) {
326 if !self.recording {
327 return;
328 }
329
330 if self.snapshots.len() >= self.capacity {
332 if self.snapshots.len() > 1 {
337 let f0 = &self.snapshots[0];
338 let f1 = &self.snapshots[1];
339
340 let mut buf = Buffer::new(f0.width, f0.height);
342 f0.apply_to(&mut buf);
343 f1.apply_to(&mut buf);
344
345 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 #[inline]
373 pub fn len(&self) -> usize {
374 self.snapshots.len()
375 }
376
377 #[inline]
379 pub fn is_empty(&self) -> bool {
380 self.snapshots.is_empty()
381 }
382
383 #[inline]
385 pub fn capacity(&self) -> usize {
386 self.capacity
387 }
388
389 #[inline]
391 pub fn frame_counter(&self) -> u64 {
392 self.frame_counter
393 }
394
395 pub fn is_recording(&self) -> bool {
397 self.recording
398 }
399
400 pub fn set_recording(&mut self, recording: bool) {
402 self.recording = recording;
403 }
404
405 pub fn get(&self, index: usize) -> Option<Buffer> {
409 if index >= self.snapshots.len() {
410 return None;
411 }
412
413 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 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 pub fn metadata(&self, index: usize) -> Option<&FrameMetadata> {
433 self.metadata.get(index)
434 }
435
436 pub fn latest_metadata(&self) -> Option<&FrameMetadata> {
438 self.metadata.back()
439 }
440
441 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 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 pub fn clear(&mut self) {
464 self.snapshots.clear();
465 self.metadata.clear();
466 self.last_buffer = None;
467 }
468
469 const MAGIC: &'static [u8] = b"FTUI-TT1";
475
476 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 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 for (snapshot, meta) in self.snapshots.iter().zip(self.metadata.iter()) {
509 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 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 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 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; for _ in 0..frame_count {
566 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 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 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#[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 assert_eq!(tt.len(), 3);
688 assert_eq!(tt.frame_counter(), 5);
689
690 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 buf.set(0, 0, Cell::from_char('A'));
702 tt.record(&buf, make_metadata(0));
703
704 buf.set(1, 0, Cell::from_char('B'));
706 tt.record(&buf, make_metadata(1));
707
708 buf.set(2, 0, Cell::from_char('C'));
710 tt.record(&buf, make_metadata(2));
711
712 buf.set(3, 0, Cell::from_char('D'));
715 tt.record(&buf, make_metadata(3));
716
717 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 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 buf.set(0, 0, Cell::from_char('A'));
739 tt.record(&buf, make_metadata(0));
740
741 buf.set(1, 0, Cell::from_char('B'));
743 tt.record(&buf, make_metadata(1));
744
745 buf.set(2, 0, Cell::from_char('C'));
747 tt.record(&buf, make_metadata(2));
748
749 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 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 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 let latest = tt.rewind(0).unwrap();
790 assert_eq!(latest.get(2, 0).unwrap().content.as_char(), Some('Z'));
791
792 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 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 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)); 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 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 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 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')); buf2.set(2, 0, Cell::from_char('C')); let cf = CompressedFrame::delta(&buf2, &buf1);
933 assert_eq!(cf.change_count(), 2);
935
936 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 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 tt.export(&path).unwrap();
994
995 let loaded = TimeTravel::import(&path).unwrap();
997 assert_eq!(loaded.len(), 2);
998
999 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 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 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 let mut buf2 = buf1.clone();
1074 for i in 0..96 {
1075 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 assert!(delta.change_count() < full.change_count());
1086 assert!(delta.memory_size() < full.memory_size());
1087 }
1088
1089 #[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 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 let debug = format!("{change:?}");
1200 assert!(debug.contains("CellChange"));
1201
1202 let cloned = change;
1204 let copied = change; assert_eq!(change, cloned);
1206 assert_eq!(change, copied);
1207
1208 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 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 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 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 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 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 assert_eq!(tt.len(), 3);
1383
1384 let latest = tt.rewind(0).unwrap();
1386 assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('F'));
1388
1389 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 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 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 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 assert_eq!(tt.len(), 3);
1536
1537 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 assert_eq!(delta.change_count(), 6);
1568 }
1569
1570 #[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); assert_eq!(a, b); 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 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 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 tt.export(&path).unwrap();
1756
1757 let loaded = TimeTravel::import(&path).unwrap();
1759 assert_eq!(loaded.len(), 3);
1760
1761 let latest = loaded.get(2).unwrap();
1763 assert_eq!(latest.get(0, 0).unwrap().content.as_char(), Some('F'));
1764
1765 let oldest = loaded.get(0).unwrap();
1767 assert_eq!(oldest.get(0, 0).unwrap().content.as_char(), Some('D'));
1768
1769 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)); 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 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 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 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 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 let mut tt = TimeTravel::new(4);
1860 let mut buf = Buffer::new(5, 1);
1861
1862 buf.set(0, 0, Cell::from_char('A'));
1864 tt.record(&buf, make_metadata(0));
1865 buf.set(1, 0, Cell::from_char('B'));
1867 tt.record(&buf, make_metadata(1));
1868 buf.set(2, 0, Cell::from_char('C'));
1870 tt.record(&buf, make_metadata(2));
1871 buf.set(3, 0, Cell::from_char('D'));
1873 tt.record(&buf, make_metadata(3));
1874 buf.set(4, 0, Cell::from_char('E'));
1876 tt.record(&buf, make_metadata(4));
1877 buf.set(0, 0, Cell::from_char('X'));
1879 tt.record(&buf, make_metadata(5));
1880
1881 assert_eq!(tt.len(), 4);
1882
1883 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 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 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 assert_eq!(tt.find_by_hash(100), None);
1932 assert_eq!(tt.find_by_hash(200), Some(0));
1934 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}