1#![forbid(unsafe_code)]
2
3use crate::buffer::{Buffer, DirtySpan};
42use crate::cell::Cell;
43
44const BLOCK_SIZE: usize = 4;
52
53const _: () = assert!(
55 core::mem::size_of::<Cell>() * BLOCK_SIZE == 64,
56 "BLOCK_SIZE * Cell must equal 64-byte cache line"
57);
58
59const _: () = assert!(
62 core::mem::align_of::<Cell>() >= 16,
63 "Cell alignment must be >= 16 for SIMD access"
64);
65
66const ROW_BLOCK_SIZE: usize = 32;
70
71#[repr(C, align(64))]
77#[allow(dead_code)]
78struct CacheLineBlock {
79 cells: [Cell; BLOCK_SIZE],
80}
81
82const _: () = assert!(
84 core::mem::size_of::<CacheLineBlock>() == 64,
85 "CacheLineBlock must be exactly 64 bytes"
86);
87
88#[cfg(test)]
89fn span_diagnostics(buf: &Buffer) -> String {
90 let stats = buf.dirty_span_stats();
91 if !buf.dirty_span_config().enabled {
92 return format!("dirty spans disabled; stats={stats:?}");
93 }
94 let mut rows = Vec::new();
95 for y in 0..buf.height() {
96 let Some(row) = buf.dirty_span_row(y) else {
97 continue;
98 };
99 if row.is_full() {
100 rows.push(format!("{y}:full"));
101 continue;
102 }
103 if row.spans().is_empty() {
104 continue;
105 }
106 let spans = row
107 .spans()
108 .iter()
109 .map(|span| format!("[{}, {})", span.x0, span.x1))
110 .collect::<Vec<_>>()
111 .join(",");
112 rows.push(format!("{y}:{spans}"));
113 }
114 format!("stats={stats:?} rows=[{}]", rows.join(" | "))
115}
116
117#[inline(always)]
124fn scan_row_changes_range(
125 old_row: &[Cell],
126 new_row: &[Cell],
127 y: u16,
128 x_offset: u16,
129 changes: &mut Vec<(u16, u16)>,
130) {
131 debug_assert_eq!(old_row.len(), new_row.len());
132 let len = old_row.len();
133 let blocks = len / BLOCK_SIZE;
134 let remainder = len % BLOCK_SIZE;
135
136 for block_idx in 0..blocks {
138 let base = block_idx * BLOCK_SIZE;
139 let base_x = x_offset + base as u16;
140 let old_block = &old_row[base..base + BLOCK_SIZE];
141 let new_block = &new_row[base..base + BLOCK_SIZE];
142
143 for i in 0..BLOCK_SIZE {
146 if !old_block[i].bits_eq(&new_block[i]) {
147 changes.push((base_x + i as u16, y));
148 }
149 }
150 }
151
152 let rem_base = blocks * BLOCK_SIZE;
154 let rem_base_x = x_offset + rem_base as u16;
155 for i in 0..remainder {
156 if !old_row[rem_base + i].bits_eq(&new_row[rem_base + i]) {
157 changes.push((rem_base_x + i as u16, y));
158 }
159 }
160}
161
162#[inline]
167fn scan_row_changes(old_row: &[Cell], new_row: &[Cell], y: u16, changes: &mut Vec<(u16, u16)>) {
168 scan_row_changes_blockwise(old_row, new_row, y, changes);
169}
170
171#[inline]
174fn scan_row_changes_blockwise(
175 old_row: &[Cell],
176 new_row: &[Cell],
177 y: u16,
178 changes: &mut Vec<(u16, u16)>,
179) {
180 debug_assert_eq!(old_row.len(), new_row.len());
181 let len = old_row.len();
182 let blocks = len / ROW_BLOCK_SIZE;
183 let remainder = len % ROW_BLOCK_SIZE;
184
185 for block_idx in 0..blocks {
186 let base = block_idx * ROW_BLOCK_SIZE;
187 let old_block = &old_row[base..base + ROW_BLOCK_SIZE];
188 let new_block = &new_row[base..base + ROW_BLOCK_SIZE];
189 if old_block == new_block {
190 continue;
191 }
192 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
193 }
194
195 if remainder > 0 {
196 let base = blocks * ROW_BLOCK_SIZE;
197 let old_block = &old_row[base..base + remainder];
198 let new_block = &new_row[base..base + remainder];
199 if old_block != new_block {
200 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
201 }
202 }
203}
204
205#[inline]
207fn scan_row_changes_spans(
208 old_row: &[Cell],
209 new_row: &[Cell],
210 y: u16,
211 spans: &[DirtySpan],
212 changes: &mut Vec<(u16, u16)>,
213) {
214 for span in spans {
215 let start = span.x0 as usize;
216 let end = span.x1 as usize;
217 if start >= end || start >= old_row.len() {
218 continue;
219 }
220 let end = end.min(old_row.len());
221 let old_slice = &old_row[start..end];
222 let new_slice = &new_row[start..end];
223 if old_slice == new_slice {
224 continue;
225 }
226 scan_row_changes_range(old_slice, new_slice, y, span.x0, changes);
227 }
228}
229
230#[inline]
231fn scan_row_changes_range_if_needed(
232 old_row: &[Cell],
233 new_row: &[Cell],
234 y: u16,
235 start: usize,
236 end: usize,
237 changes: &mut Vec<(u16, u16)>,
238) {
239 if start >= end || start >= old_row.len() {
240 return;
241 }
242 let end = end.min(old_row.len());
243 let old_slice = &old_row[start..end];
244 let new_slice = &new_row[start..end];
245 if old_slice == new_slice {
246 return;
247 }
248 scan_row_changes_range(old_slice, new_slice, y, start as u16, changes);
249}
250
251#[inline]
252#[allow(clippy::too_many_arguments)]
253fn scan_row_tiles(
254 old_row: &[Cell],
255 new_row: &[Cell],
256 y: u16,
257 width: usize,
258 tile_w: usize,
259 tiles_x: usize,
260 tile_row_base: usize,
261 dirty_tiles: &[bool],
262 changes: &mut Vec<(u16, u16)>,
263) {
264 for tile_x in 0..tiles_x {
265 let tile_idx = tile_row_base + tile_x;
266 if !dirty_tiles[tile_idx] {
267 continue;
268 }
269 let start = tile_x * tile_w;
270 let end = ((tile_x + 1) * tile_w).min(width);
271 scan_row_changes_range_if_needed(old_row, new_row, y, start, end, changes);
272 }
273}
274
275#[inline]
276#[allow(clippy::too_many_arguments)]
277fn scan_row_tiles_spans(
278 old_row: &[Cell],
279 new_row: &[Cell],
280 y: u16,
281 width: usize,
282 tile_w: usize,
283 tiles_x: usize,
284 tile_row_base: usize,
285 dirty_tiles: &[bool],
286 spans: &[DirtySpan],
287 changes: &mut Vec<(u16, u16)>,
288) {
289 if spans.is_empty() {
290 return;
291 }
292 let max_x = width.saturating_sub(1);
293 for span in spans {
294 let span_start = span.x0 as usize;
295 let span_end_exclusive = span.x1 as usize;
296 if span_start >= span_end_exclusive || span_start > max_x {
297 continue;
298 }
299 let span_end = span_end_exclusive.saturating_sub(1).min(max_x);
300 let tile_x_start = span_start / tile_w;
301 let tile_x_end = span_end / tile_w;
302 for tile_x in tile_x_start..=tile_x_end {
303 if tile_x >= tiles_x {
304 break;
305 }
306 let tile_idx = tile_row_base + tile_x;
307 if !dirty_tiles[tile_idx] {
308 continue;
309 }
310 let tile_start = tile_x * tile_w;
311 let tile_end = ((tile_x + 1) * tile_w).min(width);
312 let seg_start = span_start.max(tile_start);
313 let seg_end = span_end.min(tile_end.saturating_sub(1));
314 if seg_start > seg_end {
315 continue;
316 }
317 scan_row_changes_range_if_needed(old_row, new_row, y, seg_start, seg_end + 1, changes);
318 }
319 }
320}
321
322const TILE_SIZE_MIN: u16 = 8;
327const TILE_SIZE_MAX: u16 = 64;
328
329#[inline]
330fn clamp_tile_size(value: u16) -> u16 {
331 value.clamp(TILE_SIZE_MIN, TILE_SIZE_MAX)
332}
333
334#[inline]
335fn div_ceil_usize(n: usize, d: usize) -> usize {
336 debug_assert!(d > 0);
337 n.div_ceil(d)
338}
339
340#[derive(Debug, Clone)]
342pub struct TileDiffConfig {
343 pub enabled: bool,
345 pub tile_w: u16,
347 pub tile_h: u16,
349 pub skip_clean_rows: bool,
354 pub min_cells_for_tiles: usize,
356 pub dense_cell_ratio: f64,
358 pub dense_tile_ratio: f64,
360 pub max_tiles: usize,
362}
363
364impl Default for TileDiffConfig {
365 fn default() -> Self {
366 Self {
367 enabled: true,
368 tile_w: 16,
369 tile_h: 8,
370 skip_clean_rows: true,
371 min_cells_for_tiles: 12_000,
372 dense_cell_ratio: 0.25,
373 dense_tile_ratio: 0.60,
374 max_tiles: 4096,
375 }
376 }
377}
378
379impl TileDiffConfig {
380 #[must_use]
382 pub fn with_enabled(mut self, enabled: bool) -> Self {
383 self.enabled = enabled;
384 self
385 }
386
387 #[must_use]
389 pub fn with_tile_size(mut self, tile_w: u16, tile_h: u16) -> Self {
390 self.tile_w = tile_w;
391 self.tile_h = tile_h;
392 self
393 }
394
395 #[must_use]
397 pub fn with_min_cells_for_tiles(mut self, min_cells: usize) -> Self {
398 self.min_cells_for_tiles = min_cells;
399 self
400 }
401
402 #[must_use]
404 pub fn with_skip_clean_rows(mut self, skip: bool) -> Self {
405 self.skip_clean_rows = skip;
406 self
407 }
408
409 #[must_use]
411 pub fn with_dense_cell_ratio(mut self, ratio: f64) -> Self {
412 self.dense_cell_ratio = ratio;
413 self
414 }
415
416 #[must_use]
418 pub fn with_dense_tile_ratio(mut self, ratio: f64) -> Self {
419 self.dense_tile_ratio = ratio;
420 self
421 }
422
423 #[must_use]
425 pub fn with_max_tiles(mut self, max_tiles: usize) -> Self {
426 self.max_tiles = max_tiles;
427 self
428 }
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum TileDiffFallback {
434 Disabled,
435 SmallScreen,
436 DirtyAll,
437 DenseCells,
438 DenseTiles,
439 TooManyTiles,
440 Overflow,
441}
442
443impl TileDiffFallback {
444 pub const fn as_str(self) -> &'static str {
445 match self {
446 Self::Disabled => "disabled",
447 Self::SmallScreen => "small_screen",
448 Self::DirtyAll => "dirty_all",
449 Self::DenseCells => "dense_cells",
450 Self::DenseTiles => "dense_tiles",
451 Self::TooManyTiles => "too_many_tiles",
452 Self::Overflow => "overflow",
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy)]
459pub struct TileParams {
460 pub width: u16,
461 pub height: u16,
462 pub tile_w: u16,
463 pub tile_h: u16,
464 pub tiles_x: usize,
465 pub tiles_y: usize,
466}
467
468impl TileParams {
469 #[inline]
470 pub fn total_tiles(self) -> usize {
471 self.tiles_x * self.tiles_y
472 }
473
474 #[inline]
475 pub fn total_cells(self) -> usize {
476 self.width as usize * self.height as usize
477 }
478}
479
480#[derive(Debug, Clone, Copy)]
482pub struct TileDiffStats {
483 pub width: u16,
484 pub height: u16,
485 pub tile_w: u16,
486 pub tile_h: u16,
487 pub tiles_x: usize,
488 pub tiles_y: usize,
489 pub total_tiles: usize,
490 pub dirty_cells: usize,
491 pub dirty_tiles: usize,
492 pub dirty_cell_ratio: f64,
493 pub dirty_tile_ratio: f64,
494 pub scanned_tiles: usize,
495 pub skipped_tiles: usize,
496 pub sat_build_cells: usize,
497 pub scan_cells_estimate: usize,
498 pub fallback: Option<TileDiffFallback>,
499}
500
501#[derive(Debug, Default, Clone)]
503#[must_use]
504pub struct TileDiffBuilder {
505 tile_counts: Vec<u32>,
506 sat: Vec<u32>,
507 dirty_tiles: Vec<bool>,
508}
509
510#[derive(Debug, Clone, Copy)]
512pub struct TileDiffInput<'a> {
513 pub width: u16,
514 pub height: u16,
515 pub dirty_rows: &'a [bool],
516 pub dirty_bits: &'a [u8],
517 pub dirty_cells: usize,
518 pub dirty_all: bool,
519}
520
521#[derive(Debug, Clone)]
523pub struct TileDiffPlan<'a> {
524 pub params: TileParams,
525 pub stats: TileDiffStats,
526 pub dirty_tiles: &'a [bool],
527 pub tile_counts: &'a [u32],
528 pub sat: &'a [u32],
529}
530
531#[derive(Debug, Clone)]
533pub enum TileDiffBuild<'a> {
534 UseTiles(TileDiffPlan<'a>),
535 Fallback(TileDiffStats),
536}
537
538impl TileDiffBuilder {
539 pub fn new() -> Self {
540 Self::default()
541 }
542
543 pub fn build<'a, 'b>(
544 &'a mut self,
545 config: &TileDiffConfig,
546 input: TileDiffInput<'b>,
547 ) -> TileDiffBuild<'a> {
548 let TileDiffInput {
549 width,
550 height,
551 dirty_rows,
552 dirty_bits,
553 dirty_cells,
554 dirty_all,
555 } = input;
556 let tile_w = clamp_tile_size(config.tile_w);
557 let tile_h = clamp_tile_size(config.tile_h);
558 let width_usize = width as usize;
559 let height_usize = height as usize;
560 let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
561 let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
562 let total_tiles = tiles_x * tiles_y;
563 let total_cells = width_usize * height_usize;
564 let dirty_cell_ratio = if total_cells == 0 {
565 0.0
566 } else {
567 dirty_cells as f64 / total_cells as f64
568 };
569
570 let mut stats = TileDiffStats {
571 width,
572 height,
573 tile_w,
574 tile_h,
575 tiles_x,
576 tiles_y,
577 total_tiles,
578 dirty_cells,
579 dirty_tiles: 0,
580 dirty_cell_ratio,
581 dirty_tile_ratio: 0.0,
582 scanned_tiles: 0,
583 skipped_tiles: total_tiles,
584 sat_build_cells: 0,
585 scan_cells_estimate: 0,
586 fallback: None,
587 };
588
589 if !config.enabled {
590 stats.fallback = Some(TileDiffFallback::Disabled);
591 return TileDiffBuild::Fallback(stats);
592 }
593
594 if total_cells < config.min_cells_for_tiles {
595 stats.fallback = Some(TileDiffFallback::SmallScreen);
596 return TileDiffBuild::Fallback(stats);
597 }
598
599 if dirty_all {
600 stats.fallback = Some(TileDiffFallback::DirtyAll);
601 return TileDiffBuild::Fallback(stats);
602 }
603
604 if dirty_cell_ratio >= config.dense_cell_ratio {
605 stats.fallback = Some(TileDiffFallback::DenseCells);
606 return TileDiffBuild::Fallback(stats);
607 }
608
609 if total_tiles > config.max_tiles {
610 stats.fallback = Some(TileDiffFallback::TooManyTiles);
611 return TileDiffBuild::Fallback(stats);
612 }
613
614 debug_assert_eq!(dirty_bits.len(), total_cells);
615 if dirty_bits.len() < total_cells {
616 stats.fallback = Some(TileDiffFallback::Overflow);
617 return TileDiffBuild::Fallback(stats);
618 }
619
620 self.tile_counts.resize(total_tiles, 0);
621 self.tile_counts.fill(0);
622 self.dirty_tiles.resize(total_tiles, false);
623 self.dirty_tiles.fill(false);
624
625 let tile_w_usize = tile_w as usize;
626 let tile_h_usize = tile_h as usize;
627 let mut overflow = false;
628 let mut scanned_cells = 0usize;
629
630 debug_assert_eq!(dirty_rows.len(), height_usize);
631 for y in 0..height_usize {
632 let row_dirty = if config.skip_clean_rows {
633 dirty_rows.get(y).copied().unwrap_or(true)
634 } else {
635 true
636 };
637 if !row_dirty {
638 continue;
639 }
640 scanned_cells = scanned_cells.saturating_add(width_usize);
641 let row_start = y * width_usize;
642 let tile_y = y / tile_h_usize;
643 for x in 0..width_usize {
644 let idx = row_start + x;
645 if dirty_bits[idx] == 0 {
646 continue;
647 }
648 let tile_x = x / tile_w_usize;
649 let tile_idx = tile_y * tiles_x + tile_x;
650 match self.tile_counts[tile_idx].checked_add(1) {
651 Some(value) => self.tile_counts[tile_idx] = value,
652 None => {
653 overflow = true;
654 break;
655 }
656 }
657 }
658 if overflow {
659 break;
660 }
661 }
662
663 if overflow {
664 stats.fallback = Some(TileDiffFallback::Overflow);
665 return TileDiffBuild::Fallback(stats);
666 }
667
668 let mut dirty_tiles = 0usize;
669 for (idx, count) in self.tile_counts.iter().enumerate() {
670 if *count > 0 {
671 self.dirty_tiles[idx] = true;
672 dirty_tiles += 1;
673 }
674 }
675
676 stats.dirty_tiles = dirty_tiles;
677 stats.dirty_tile_ratio = if total_tiles == 0 {
678 0.0
679 } else {
680 dirty_tiles as f64 / total_tiles as f64
681 };
682 stats.scanned_tiles = dirty_tiles;
683 stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
684 stats.sat_build_cells = scanned_cells;
685 stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
686
687 if stats.dirty_tile_ratio >= config.dense_tile_ratio {
688 stats.fallback = Some(TileDiffFallback::DenseTiles);
689 return TileDiffBuild::Fallback(stats);
690 }
691
692 let sat_w = tiles_x + 1;
693 let sat_h = tiles_y + 1;
694 let sat_len = sat_w * sat_h;
695 self.sat.resize(sat_len, 0);
696 self.sat.fill(0);
697
698 for ty in 0..tiles_y {
699 let row_base = (ty + 1) * sat_w;
700 let prev_base = ty * sat_w;
701 for tx in 0..tiles_x {
702 let count = self.tile_counts[ty * tiles_x + tx] as u64;
703 let above = self.sat[prev_base + tx + 1] as u64;
704 let left = self.sat[row_base + tx] as u64;
705 let diag = self.sat[prev_base + tx] as u64;
706 let value = count + above + left - diag;
707 if value > u32::MAX as u64 {
708 stats.fallback = Some(TileDiffFallback::Overflow);
709 return TileDiffBuild::Fallback(stats);
710 }
711 self.sat[row_base + tx + 1] = value as u32;
712 }
713 }
714
715 let params = TileParams {
716 width,
717 height,
718 tile_w,
719 tile_h,
720 tiles_x,
721 tiles_y,
722 };
723
724 TileDiffBuild::UseTiles(TileDiffPlan {
725 params,
726 stats,
727 dirty_tiles: &self.dirty_tiles,
728 tile_counts: &self.tile_counts,
729 sat: &self.sat,
730 })
731 }
732}
733
734#[inline]
735fn reserve_changes_capacity(width: u16, height: u16, changes: &mut Vec<(u16, u16)>) {
736 let estimated_changes = (width as usize * height as usize) / 20;
738 let additional = estimated_changes.saturating_sub(changes.len());
739 if additional > 0 {
740 changes.reserve(additional);
741 }
742}
743
744fn compute_changes(old: &Buffer, new: &Buffer, changes: &mut Vec<(u16, u16)>) {
745 #[cfg(feature = "tracing")]
746 let _span = tracing::debug_span!("diff_compute", width = old.width(), height = old.height());
747 #[cfg(feature = "tracing")]
748 let _guard = _span.enter();
749
750 assert_eq!(old.width(), new.width(), "buffer widths must match");
751 assert_eq!(old.height(), new.height(), "buffer heights must match");
752
753 let width = old.width();
754 let height = old.height();
755 let w = width as usize;
756
757 changes.clear();
758 reserve_changes_capacity(width, height, changes);
759
760 let old_cells = old.cells();
761 let new_cells = new.cells();
762
763 for y in 0..height {
765 let row_start = y as usize * w;
766 let old_row = &old_cells[row_start..row_start + w];
767 let new_row = &new_cells[row_start..row_start + w];
768
769 scan_row_changes(old_row, new_row, y, changes);
773 }
774
775 #[cfg(feature = "tracing")]
776 tracing::trace!(changes = changes.len(), "diff computed");
777}
778
779fn compute_dirty_changes(
780 old: &Buffer,
781 new: &Buffer,
782 changes: &mut Vec<(u16, u16)>,
783 tile_builder: &mut TileDiffBuilder,
784 tile_config: &TileDiffConfig,
785 tile_stats_out: &mut Option<TileDiffStats>,
786) {
787 assert_eq!(old.width(), new.width(), "buffer widths must match");
788 assert_eq!(old.height(), new.height(), "buffer heights must match");
789
790 let width = old.width();
791 let height = old.height();
792 let w = width as usize;
793
794 changes.clear();
795 reserve_changes_capacity(width, height, changes);
796
797 let old_cells = old.cells();
798 let new_cells = new.cells();
799 let dirty = new.dirty_rows();
800
801 *tile_stats_out = None;
802 let tile_build = tile_builder.build(
803 tile_config,
804 TileDiffInput {
805 width,
806 height,
807 dirty_rows: dirty,
808 dirty_bits: new.dirty_bits(),
809 dirty_cells: new.dirty_cell_count(),
810 dirty_all: new.dirty_all(),
811 },
812 );
813
814 if let TileDiffBuild::UseTiles(plan) = tile_build {
815 *tile_stats_out = Some(plan.stats);
816 let tile_w = plan.params.tile_w as usize;
817 let tile_h = plan.params.tile_h as usize;
818 let tiles_x = plan.params.tiles_x;
819 let dirty_tiles = plan.dirty_tiles;
820
821 for y in 0..height {
822 if !dirty[y as usize] {
823 continue;
824 }
825
826 let row_start = y as usize * w;
827 let old_row = &old_cells[row_start..row_start + w];
828 let new_row = &new_cells[row_start..row_start + w];
829
830 if old_row == new_row {
831 continue;
832 }
833
834 let tile_y = y as usize / tile_h;
835 let tile_row_base = tile_y * tiles_x;
836 debug_assert!(tile_row_base + tiles_x <= dirty_tiles.len());
837
838 let span_row = new.dirty_span_row(y);
839 if let Some(span_row) = span_row {
840 if span_row.is_full() {
841 scan_row_tiles(
842 old_row,
843 new_row,
844 y,
845 w,
846 tile_w,
847 tiles_x,
848 tile_row_base,
849 dirty_tiles,
850 changes,
851 );
852 continue;
853 }
854 let spans = span_row.spans();
855 if spans.is_empty() {
856 scan_row_tiles(
857 old_row,
858 new_row,
859 y,
860 w,
861 tile_w,
862 tiles_x,
863 tile_row_base,
864 dirty_tiles,
865 changes,
866 );
867 continue;
868 }
869 scan_row_tiles_spans(
870 old_row,
871 new_row,
872 y,
873 w,
874 tile_w,
875 tiles_x,
876 tile_row_base,
877 dirty_tiles,
878 spans,
879 changes,
880 );
881 } else {
882 scan_row_tiles(
883 old_row,
884 new_row,
885 y,
886 w,
887 tile_w,
888 tiles_x,
889 tile_row_base,
890 dirty_tiles,
891 changes,
892 );
893 }
894 }
895 return;
896 }
897
898 if let TileDiffBuild::Fallback(stats) = tile_build {
899 *tile_stats_out = Some(stats);
900 }
901
902 for y in 0..height {
903 if !dirty[y as usize] {
905 continue;
906 }
907
908 let row_start = y as usize * w;
909 let old_row = &old_cells[row_start..row_start + w];
910 let new_row = &new_cells[row_start..row_start + w];
911
912 if old_row == new_row {
915 continue;
916 }
917
918 let span_row = new.dirty_span_row(y);
919 if let Some(span_row) = span_row {
920 if span_row.is_full() {
921 scan_row_changes(old_row, new_row, y, changes);
922 continue;
923 }
924 let spans = span_row.spans();
925 if spans.is_empty() {
926 scan_row_changes(old_row, new_row, y, changes);
927 continue;
928 }
929 scan_row_changes_spans(old_row, new_row, y, spans, changes);
930 } else {
931 scan_row_changes(old_row, new_row, y, changes);
932 }
933 }
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941pub struct ChangeRun {
942 pub y: u16,
944 pub x0: u16,
946 pub x1: u16,
948}
949
950impl ChangeRun {
951 #[inline]
953 pub const fn new(y: u16, x0: u16, x1: u16) -> Self {
954 debug_assert!(x0 <= x1);
955 Self { y, x0, x1 }
956 }
957
958 #[inline]
960 pub const fn len(&self) -> u16 {
961 self.x1 - self.x0 + 1
962 }
963
964 #[inline]
966 pub const fn is_empty(&self) -> bool {
967 self.x1 < self.x0
968 }
969}
970
971#[derive(Debug, Clone, Default)]
975pub struct BufferDiff {
976 changes: Vec<(u16, u16)>,
978 tile_builder: TileDiffBuilder,
980 tile_config: TileDiffConfig,
982 last_tile_stats: Option<TileDiffStats>,
984}
985
986impl BufferDiff {
987 pub fn new() -> Self {
989 Self {
990 changes: Vec::new(),
991 tile_builder: TileDiffBuilder::default(),
992 tile_config: TileDiffConfig::default(),
993 last_tile_stats: None,
994 }
995 }
996
997 pub fn with_capacity(capacity: usize) -> Self {
999 let mut diff = Self::new();
1000 diff.changes = Vec::with_capacity(capacity);
1001 diff
1002 }
1003
1004 pub fn full(width: u16, height: u16) -> Self {
1009 if width == 0 || height == 0 {
1010 return Self::new();
1011 }
1012
1013 let total = width as usize * height as usize;
1014 let mut changes = Vec::with_capacity(total);
1015 for y in 0..height {
1016 for x in 0..width {
1017 changes.push((x, y));
1018 }
1019 }
1020 let mut diff = Self::new();
1021 diff.changes = changes;
1022 diff
1023 }
1024
1025 pub fn compute(old: &Buffer, new: &Buffer) -> Self {
1048 let mut diff = Self::new();
1049 diff.compute_into(old, new);
1050 diff
1051 }
1052
1053 pub fn compute_into(&mut self, old: &Buffer, new: &Buffer) {
1055 self.last_tile_stats = None;
1056 compute_changes(old, new, &mut self.changes);
1057 }
1058
1059 pub fn compute_dirty(old: &Buffer, new: &Buffer) -> Self {
1071 let mut diff = Self::new();
1072 diff.compute_dirty_into(old, new);
1073 diff
1074 }
1075
1076 pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
1078 compute_dirty_changes(
1079 old,
1080 new,
1081 &mut self.changes,
1082 &mut self.tile_builder,
1083 &self.tile_config,
1084 &mut self.last_tile_stats,
1085 );
1086 }
1087
1088 #[inline]
1090 pub fn len(&self) -> usize {
1091 self.changes.len()
1092 }
1093
1094 #[inline]
1096 pub fn is_empty(&self) -> bool {
1097 self.changes.is_empty()
1098 }
1099
1100 #[inline]
1102 pub fn changes(&self) -> &[(u16, u16)] {
1103 &self.changes
1104 }
1105
1106 #[inline]
1108 pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
1109 self.last_tile_stats
1110 }
1111
1112 #[inline]
1114 pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
1115 &mut self.tile_config
1116 }
1117
1118 pub fn runs(&self) -> Vec<ChangeRun> {
1123 #[cfg(feature = "tracing")]
1124 let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
1125 #[cfg(feature = "tracing")]
1126 let _guard = _span.enter();
1127
1128 if self.changes.is_empty() {
1129 return Vec::new();
1130 }
1131
1132 let sorted = &self.changes;
1135 let len = sorted.len();
1136
1137 let mut runs = Vec::with_capacity(len);
1140
1141 let mut i = 0;
1142
1143 while i < len {
1144 let (x0, y) = sorted[i];
1145 let mut x1 = x0;
1146 i += 1;
1147
1148 while i < len {
1150 let (x, yy) = sorted[i];
1151 if yy != y || x != x1.saturating_add(1) {
1152 break;
1153 }
1154 x1 = x;
1155 i += 1;
1156 }
1157
1158 runs.push(ChangeRun::new(y, x0, x1));
1159 }
1160
1161 #[cfg(feature = "tracing")]
1162 tracing::trace!(run_count = runs.len(), "runs coalesced");
1163
1164 runs
1165 }
1166
1167 pub fn runs_into(&self, out: &mut Vec<ChangeRun>) {
1170 out.clear();
1171
1172 #[cfg(feature = "tracing")]
1173 let _span = tracing::debug_span!("diff_runs_into", changes = self.changes.len());
1174 #[cfg(feature = "tracing")]
1175 let _guard = _span.enter();
1176
1177 if self.changes.is_empty() {
1178 return;
1179 }
1180
1181 let sorted = &self.changes;
1182 let len = sorted.len();
1183 let mut i = 0;
1184
1185 while i < len {
1186 let (x0, y) = sorted[i];
1187 let mut x1 = x0;
1188 i += 1;
1189
1190 while i < len {
1191 let (x, yy) = sorted[i];
1192 if yy != y || x != x1.saturating_add(1) {
1193 break;
1194 }
1195 x1 = x;
1196 i += 1;
1197 }
1198
1199 out.push(ChangeRun::new(y, x0, x1));
1200 }
1201
1202 #[cfg(feature = "tracing")]
1203 tracing::trace!(run_count = out.len(), "runs coalesced (reuse)");
1204 }
1205
1206 #[inline]
1208 pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
1209 self.changes.iter().copied()
1210 }
1211
1212 pub fn clear(&mut self) {
1214 self.changes.clear();
1215 }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220 fn is_coverage_run() -> bool {
1221 if let Ok(value) = std::env::var("FTUI_COVERAGE") {
1222 let value = value.to_ascii_lowercase();
1223 if matches!(value.as_str(), "1" | "true" | "yes") {
1224 return true;
1225 }
1226 if matches!(value.as_str(), "0" | "false" | "no") {
1227 return false;
1228 }
1229 }
1230 std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
1231 }
1232 use super::*;
1233 use crate::cell::{Cell, PackedRgba};
1234
1235 #[test]
1236 fn empty_diff_when_buffers_identical() {
1237 let buf1 = Buffer::new(10, 10);
1238 let buf2 = Buffer::new(10, 10);
1239 let diff = BufferDiff::compute(&buf1, &buf2);
1240
1241 assert!(diff.is_empty());
1242 assert_eq!(diff.len(), 0);
1243 }
1244
1245 #[test]
1246 fn full_diff_marks_all_cells() {
1247 let diff = BufferDiff::full(3, 2);
1248 assert_eq!(diff.len(), 6);
1249 assert_eq!(diff.changes()[0], (0, 0));
1250 assert_eq!(diff.changes()[5], (2, 1));
1251 }
1252
1253 #[test]
1254 fn single_cell_change_detected() {
1255 let old = Buffer::new(10, 10);
1256 let mut new = Buffer::new(10, 10);
1257
1258 new.set_raw(5, 5, Cell::from_char('X'));
1259 let diff = BufferDiff::compute(&old, &new);
1260
1261 assert_eq!(diff.len(), 1);
1262 assert_eq!(diff.changes(), &[(5, 5)]);
1263 }
1264
1265 #[test]
1266 fn dirty_row_false_positive_skipped() {
1267 let old = Buffer::new(8, 2);
1268 let mut new = old.clone();
1269
1270 new.clear_dirty();
1272 new.set_raw(2, 0, Cell::default());
1274
1275 let diff = BufferDiff::compute_dirty(&old, &new);
1276 assert!(
1277 diff.is_empty(),
1278 "dirty row with no changes should be skipped"
1279 );
1280 }
1281
1282 #[test]
1283 fn multiple_scattered_changes_detected() {
1284 let old = Buffer::new(10, 10);
1285 let mut new = Buffer::new(10, 10);
1286
1287 new.set_raw(0, 0, Cell::from_char('A'));
1288 new.set_raw(9, 9, Cell::from_char('B'));
1289 new.set_raw(5, 3, Cell::from_char('C'));
1290
1291 let diff = BufferDiff::compute(&old, &new);
1292
1293 assert_eq!(diff.len(), 3);
1294 let changes = diff.changes();
1296 assert!(changes.contains(&(0, 0)));
1297 assert!(changes.contains(&(9, 9)));
1298 assert!(changes.contains(&(5, 3)));
1299 }
1300
1301 #[test]
1302 fn runs_coalesces_adjacent_cells() {
1303 let old = Buffer::new(10, 10);
1304 let mut new = Buffer::new(10, 10);
1305
1306 new.set_raw(3, 5, Cell::from_char('A'));
1308 new.set_raw(4, 5, Cell::from_char('B'));
1309 new.set_raw(5, 5, Cell::from_char('C'));
1310
1311 let diff = BufferDiff::compute(&old, &new);
1312 let runs = diff.runs();
1313
1314 assert_eq!(runs.len(), 1);
1315 assert_eq!(runs[0].y, 5);
1316 assert_eq!(runs[0].x0, 3);
1317 assert_eq!(runs[0].x1, 5);
1318 assert_eq!(runs[0].len(), 3);
1319 }
1320
1321 #[test]
1322 fn runs_handles_gaps_correctly() {
1323 let old = Buffer::new(10, 10);
1324 let mut new = Buffer::new(10, 10);
1325
1326 new.set_raw(0, 0, Cell::from_char('A'));
1328 new.set_raw(1, 0, Cell::from_char('B'));
1329 new.set_raw(3, 0, Cell::from_char('C'));
1331 new.set_raw(4, 0, Cell::from_char('D'));
1332
1333 let diff = BufferDiff::compute(&old, &new);
1334 let runs = diff.runs();
1335
1336 assert_eq!(runs.len(), 2);
1337
1338 assert_eq!(runs[0].y, 0);
1339 assert_eq!(runs[0].x0, 0);
1340 assert_eq!(runs[0].x1, 1);
1341
1342 assert_eq!(runs[1].y, 0);
1343 assert_eq!(runs[1].x0, 3);
1344 assert_eq!(runs[1].x1, 4);
1345 }
1346
1347 #[test]
1348 fn runs_handles_max_column_without_overflow() {
1349 let mut diff = BufferDiff::new();
1350 diff.changes = vec![(u16::MAX, 0)];
1351
1352 let runs = diff.runs();
1353
1354 assert_eq!(runs.len(), 1);
1355 assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1356 }
1357
1358 #[test]
1359 fn runs_handles_multiple_rows() {
1360 let old = Buffer::new(10, 10);
1361 let mut new = Buffer::new(10, 10);
1362
1363 new.set_raw(0, 0, Cell::from_char('A'));
1365 new.set_raw(1, 0, Cell::from_char('B'));
1366 new.set_raw(5, 2, Cell::from_char('C'));
1367 new.set_raw(0, 5, Cell::from_char('D'));
1368
1369 let diff = BufferDiff::compute(&old, &new);
1370 let runs = diff.runs();
1371
1372 assert_eq!(runs.len(), 3);
1373
1374 assert_eq!(runs[0].y, 0);
1376 assert_eq!(runs[0].x0, 0);
1377 assert_eq!(runs[0].x1, 1);
1378
1379 assert_eq!(runs[1].y, 2);
1381 assert_eq!(runs[1].x0, 5);
1382 assert_eq!(runs[1].x1, 5);
1383
1384 assert_eq!(runs[2].y, 5);
1386 assert_eq!(runs[2].x0, 0);
1387 assert_eq!(runs[2].x1, 0);
1388 }
1389
1390 #[test]
1391 fn empty_runs_from_empty_diff() {
1392 let diff = BufferDiff::new();
1393 let runs = diff.runs();
1394 assert!(runs.is_empty());
1395 }
1396
1397 #[test]
1398 fn change_run_len() {
1399 let run = ChangeRun::new(0, 5, 10);
1400 assert_eq!(run.len(), 6);
1401
1402 let single = ChangeRun::new(0, 5, 5);
1403 assert_eq!(single.len(), 1);
1404 }
1405
1406 #[test]
1407 fn color_changes_detected() {
1408 let old = Buffer::new(10, 10);
1409 let mut new = Buffer::new(10, 10);
1410
1411 new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1413
1414 let diff = BufferDiff::compute(&old, &new);
1415 assert_eq!(diff.len(), 1);
1416 }
1417
1418 #[test]
1419 fn diff_iter() {
1420 let old = Buffer::new(5, 5);
1421 let mut new = Buffer::new(5, 5);
1422 new.set_raw(1, 1, Cell::from_char('X'));
1423 new.set_raw(2, 2, Cell::from_char('Y'));
1424
1425 let diff = BufferDiff::compute(&old, &new);
1426 let positions: Vec<_> = diff.iter().collect();
1427
1428 assert_eq!(positions.len(), 2);
1429 assert!(positions.contains(&(1, 1)));
1430 assert!(positions.contains(&(2, 2)));
1431 }
1432
1433 #[test]
1434 fn diff_clear() {
1435 let old = Buffer::new(5, 5);
1436 let mut new = Buffer::new(5, 5);
1437 new.set_raw(1, 1, Cell::from_char('X'));
1438
1439 let mut diff = BufferDiff::compute(&old, &new);
1440 assert_eq!(diff.len(), 1);
1441
1442 diff.clear();
1443 assert!(diff.is_empty());
1444 }
1445
1446 #[test]
1447 fn with_capacity() {
1448 let diff = BufferDiff::with_capacity(100);
1449 assert!(diff.is_empty());
1450 }
1451
1452 #[test]
1453 fn full_buffer_change() {
1454 let old = Buffer::new(5, 5);
1455 let mut new = Buffer::new(5, 5);
1456
1457 for y in 0..5 {
1459 for x in 0..5 {
1460 new.set_raw(x, y, Cell::from_char('#'));
1461 }
1462 }
1463
1464 let diff = BufferDiff::compute(&old, &new);
1465 assert_eq!(diff.len(), 25);
1466
1467 let runs = diff.runs();
1469 assert_eq!(runs.len(), 5);
1470
1471 for (i, run) in runs.iter().enumerate() {
1472 assert_eq!(run.y, i as u16);
1473 assert_eq!(run.x0, 0);
1474 assert_eq!(run.x1, 4);
1475 assert_eq!(run.len(), 5);
1476 }
1477 }
1478
1479 #[test]
1480 fn row_major_order_preserved() {
1481 let old = Buffer::new(3, 3);
1482 let mut new = Buffer::new(3, 3);
1483
1484 new.set_raw(2, 2, Cell::from_char('C'));
1486 new.set_raw(0, 0, Cell::from_char('A'));
1487 new.set_raw(1, 1, Cell::from_char('B'));
1488
1489 let diff = BufferDiff::compute(&old, &new);
1490
1491 let changes = diff.changes();
1493 assert_eq!(changes[0], (0, 0));
1494 assert_eq!(changes[1], (1, 1));
1495 assert_eq!(changes[2], (2, 2));
1496 }
1497
1498 #[test]
1499 fn blockwise_scan_preserves_sparse_row_changes() {
1500 let old = Buffer::new(64, 2);
1501 let mut new = old.clone();
1502
1503 new.set_raw(1, 0, Cell::from_char('A'));
1504 new.set_raw(33, 0, Cell::from_char('B'));
1505 new.set_raw(62, 1, Cell::from_char('C'));
1506
1507 let diff = BufferDiff::compute(&old, &new);
1508 assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1509 }
1510
1511 #[test]
1512 fn rows_with_no_changes_are_skipped() {
1513 let old = Buffer::new(4, 3);
1514 let mut new = old.clone();
1515
1516 new.set_raw(1, 1, Cell::from_char('X'));
1517 new.set_raw(3, 1, Cell::from_char('Y'));
1518
1519 let diff = BufferDiff::compute(&old, &new);
1520 assert_eq!(diff.len(), 2);
1521 assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1522 }
1523
1524 #[test]
1525 fn clear_retains_capacity_for_reuse() {
1526 let mut diff = BufferDiff::with_capacity(16);
1527 diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1528 let capacity = diff.changes.capacity();
1529
1530 diff.clear();
1531
1532 assert!(diff.is_empty());
1533 assert!(diff.changes.capacity() >= capacity);
1534 }
1535
1536 #[test]
1537 #[should_panic(expected = "buffer widths must match")]
1538 fn compute_panics_on_width_mismatch() {
1539 let old = Buffer::new(5, 5);
1540 let new = Buffer::new(4, 5);
1541 let _ = BufferDiff::compute(&old, &new);
1542 }
1543
1544 #[test]
1549 fn block_scan_alignment_exact_block() {
1550 let old = Buffer::new(4, 1);
1552 let mut new = Buffer::new(4, 1);
1553 new.set_raw(2, 0, Cell::from_char('X'));
1554
1555 let diff = BufferDiff::compute(&old, &new);
1556 assert_eq!(diff.len(), 1);
1557 assert_eq!(diff.changes(), &[(2, 0)]);
1558 }
1559
1560 #[test]
1561 fn block_scan_alignment_remainder() {
1562 let old = Buffer::new(7, 1);
1564 let mut new = Buffer::new(7, 1);
1565 new.set_raw(1, 0, Cell::from_char('A'));
1567 new.set_raw(5, 0, Cell::from_char('B'));
1569 new.set_raw(6, 0, Cell::from_char('C'));
1570
1571 let diff = BufferDiff::compute(&old, &new);
1572 assert_eq!(diff.len(), 3);
1573 assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1574 }
1575
1576 #[test]
1577 fn block_scan_single_cell_row() {
1578 let old = Buffer::new(1, 1);
1580 let mut new = Buffer::new(1, 1);
1581 new.set_raw(0, 0, Cell::from_char('X'));
1582
1583 let diff = BufferDiff::compute(&old, &new);
1584 assert_eq!(diff.len(), 1);
1585 assert_eq!(diff.changes(), &[(0, 0)]);
1586 }
1587
1588 #[test]
1589 fn block_scan_two_cell_row() {
1590 let old = Buffer::new(2, 1);
1592 let mut new = Buffer::new(2, 1);
1593 new.set_raw(0, 0, Cell::from_char('A'));
1594 new.set_raw(1, 0, Cell::from_char('B'));
1595
1596 let diff = BufferDiff::compute(&old, &new);
1597 assert_eq!(diff.len(), 2);
1598 assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
1599 }
1600
1601 #[test]
1602 fn block_scan_three_cell_row() {
1603 let old = Buffer::new(3, 1);
1605 let mut new = Buffer::new(3, 1);
1606 new.set_raw(2, 0, Cell::from_char('X'));
1607
1608 let diff = BufferDiff::compute(&old, &new);
1609 assert_eq!(diff.len(), 1);
1610 assert_eq!(diff.changes(), &[(2, 0)]);
1611 }
1612
1613 #[test]
1614 fn block_scan_multiple_blocks_sparse() {
1615 let old = Buffer::new(80, 1);
1617 let mut new = Buffer::new(80, 1);
1618
1619 for block in (0..20).step_by(2) {
1621 let x = (block * 4 + 1) as u16;
1622 new.set_raw(x, 0, Cell::from_char('X'));
1623 }
1624
1625 let diff = BufferDiff::compute(&old, &new);
1626 assert_eq!(diff.len(), 10);
1627 assert_eq!(
1628 diff.changes(),
1629 &[
1630 (1, 0),
1631 (9, 0),
1632 (17, 0),
1633 (25, 0),
1634 (33, 0),
1635 (41, 0),
1636 (49, 0),
1637 (57, 0),
1638 (65, 0),
1639 (73, 0)
1640 ]
1641 );
1642 }
1643
1644 #[test]
1645 fn block_scan_full_block_unchanged_skip() {
1646 let old = Buffer::new(20, 1);
1648 let mut new = Buffer::new(20, 1);
1649
1650 new.set_raw(19, 0, Cell::from_char('Z'));
1652
1653 let diff = BufferDiff::compute(&old, &new);
1654 assert_eq!(diff.len(), 1);
1655 assert_eq!(diff.changes(), &[(19, 0)]);
1656 }
1657
1658 #[test]
1659 fn block_scan_wide_row_all_changed() {
1660 let old = Buffer::new(120, 1);
1662 let mut new = Buffer::new(120, 1);
1663 for x in 0..120 {
1664 new.set_raw(x, 0, Cell::from_char('#'));
1665 }
1666
1667 let diff = BufferDiff::compute(&old, &new);
1668 assert_eq!(diff.len(), 120);
1669 }
1670
1671 #[test]
1672 fn perf_block_scan_vs_scalar_baseline() {
1673 use std::time::Instant;
1676
1677 let width = 200u16;
1678 let height = 50u16;
1679 let old = Buffer::new(width, height);
1680 let mut new = Buffer::new(width, height);
1681
1682 for i in 0..1000 {
1684 let x = (i * 7 + 3) as u16 % width;
1685 let y = (i * 11 + 5) as u16 % height;
1686 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
1687 new.set_raw(x, y, Cell::from_char(ch));
1688 }
1689
1690 let iterations = 1000u32;
1691 let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
1692 .ok()
1693 .and_then(|value| value.parse::<usize>().ok())
1694 .unwrap_or(50)
1695 .clamp(1, iterations as usize);
1696 let iters_per_sample = (iterations / samples as u32).max(1) as u64;
1697
1698 let mut times_us = Vec::with_capacity(samples);
1699 let mut last_checksum = 0u64;
1700
1701 for _ in 0..samples {
1702 let start = Instant::now();
1703 for _ in 0..iters_per_sample {
1704 let diff = BufferDiff::compute(&old, &new);
1705 assert!(!diff.is_empty());
1706 last_checksum = fnv1a_hash(diff.changes());
1707 }
1708 let elapsed = start.elapsed();
1709 let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
1710 times_us.push(per_iter);
1711 }
1712
1713 times_us.sort_unstable();
1714 let len = times_us.len();
1715 let p50 = times_us[len / 2];
1716 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1717 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1718 let mean = times_us
1719 .iter()
1720 .copied()
1721 .map(|value| value as f64)
1722 .sum::<f64>()
1723 / len as f64;
1724 let variance = times_us
1725 .iter()
1726 .map(|value| {
1727 let delta = *value as f64 - mean;
1728 delta * delta
1729 })
1730 .sum::<f64>()
1731 / len as f64;
1732
1733 eprintln!(
1735 "{{\"ts\":\"2026-02-04T00:00:00Z\",\"event\":\"block_scan_baseline\",\"width\":{},\"height\":{},\"samples\":{},\"iters_per_sample\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"mean_us\":{:.2},\"variance_us\":{:.2},\"checksum\":\"0x{:016x}\"}}",
1736 width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
1737 );
1738
1739 #[allow(unexpected_cfgs)]
1743 let p50_budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
1744 #[allow(unexpected_cfgs)]
1745 let p95_budget_us = if is_coverage_run() {
1746 10_000u64
1747 } else {
1748 2_500u64
1749 };
1750
1751 assert!(
1752 p50 <= p50_budget_us,
1753 "Diff too slow: p50={p50}µs (budget {p50_budget_us}µs) for {width}x{height}"
1754 );
1755 assert!(
1756 p95 <= p95_budget_us,
1757 "Diff tail too slow: p95={p95}µs (budget {p95_budget_us}µs) for {width}x{height}"
1758 );
1759 }
1760 #[test]
1765 fn unit_run_coalescing_invariants() {
1766 let old = Buffer::new(80, 24);
1769 let mut new = Buffer::new(80, 24);
1770
1771 for x in 0..=2 {
1773 new.set_raw(x, 0, Cell::from_char('A'));
1774 }
1775 for x in 10..=12 {
1776 new.set_raw(x, 0, Cell::from_char('B'));
1777 }
1778 for x in 40..=45 {
1780 new.set_raw(x, 5, Cell::from_char('C'));
1781 }
1782 new.set_raw(79, 23, Cell::from_char('Z'));
1784
1785 let diff = BufferDiff::compute(&old, &new);
1786 let runs = diff.runs();
1787
1788 for w in runs.windows(2) {
1790 assert!(
1791 (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
1792 "runs must be sorted: {:?} should precede {:?}",
1793 w[0],
1794 w[1]
1795 );
1796 }
1797
1798 let total_cells: usize = runs.iter().map(|r| r.len() as usize).sum();
1800 assert_eq!(
1801 total_cells,
1802 diff.len(),
1803 "runs must cover all changes exactly"
1804 );
1805
1806 for w in runs.windows(2) {
1808 if w[0].y == w[1].y {
1809 assert!(
1810 w[1].x0 > w[0].x1.saturating_add(1),
1811 "adjacent runs on same row should be merged: {:?} and {:?}",
1812 w[0],
1813 w[1]
1814 );
1815 }
1816 }
1817
1818 assert_eq!(runs.len(), 4);
1820 assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
1821 assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
1822 assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
1823 assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
1824 }
1825
1826 fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
1832 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1833 for &(x, y) in data {
1834 for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
1835 hash ^= *byte as u64;
1836 hash = hash.wrapping_mul(0x0100_0000_01b3);
1837 }
1838 }
1839 hash
1840 }
1841
1842 fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
1845 let mut buf = Buffer::new(width, height);
1846 let mut rng = seed;
1847
1848 for x in 0..width {
1850 buf.set_raw(x, 0, Cell::from_char('='));
1851 }
1852
1853 for x in 0..width {
1855 buf.set_raw(x, height - 1, Cell::from_char('-'));
1856 }
1857
1858 let count = (width as u64 * height as u64 / 10).max(5);
1860 for _ in 0..count {
1861 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1862 let x = ((rng >> 16) as u16) % width;
1863 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1864 let y = ((rng >> 16) as u16) % height;
1865 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1866 let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
1867 buf.set_raw(x, y, Cell::from_char(ch));
1868 }
1869
1870 buf
1871 }
1872
1873 #[test]
1874 fn golden_diff_80x24() {
1875 let old = Buffer::new(80, 24);
1876 let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
1877
1878 let diff = BufferDiff::compute(&old, &new);
1879 let checksum = fnv1a_hash(diff.changes());
1880
1881 let diff2 = BufferDiff::compute(&old, &new);
1883 assert_eq!(
1884 fnv1a_hash(diff2.changes()),
1885 checksum,
1886 "diff must be deterministic"
1887 );
1888
1889 assert!(
1891 diff.len() >= 160,
1892 "80x24 golden scene should have at least 160 changes (header+status), got {}",
1893 diff.len()
1894 );
1895
1896 let runs = diff.runs();
1897 assert_eq!(runs[0].y, 0, "first run should be header row");
1899 assert_eq!(runs[0].x0, 0);
1900 assert_eq!(runs[0].x1, 79);
1901 assert!(
1902 runs.last().unwrap().y == 23,
1903 "last row should contain status bar"
1904 );
1905 }
1906
1907 #[test]
1908 fn golden_diff_120x40() {
1909 let old = Buffer::new(120, 40);
1910 let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
1911
1912 let diff = BufferDiff::compute(&old, &new);
1913 let checksum = fnv1a_hash(diff.changes());
1914
1915 let diff2 = BufferDiff::compute(&old, &new);
1917 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1918
1919 assert!(
1921 diff.len() >= 240,
1922 "120x40 golden scene should have >=240 changes, got {}",
1923 diff.len()
1924 );
1925
1926 let dirty = BufferDiff::compute_dirty(&old, &new);
1928 assert_eq!(
1929 fnv1a_hash(dirty.changes()),
1930 checksum,
1931 "dirty diff must produce identical changes"
1932 );
1933 }
1934
1935 #[test]
1936 fn golden_sparse_update() {
1937 let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
1939 let mut new = old.clone();
1940
1941 new.set_raw(10, 5, Cell::from_char('!'));
1943 new.set_raw(11, 5, Cell::from_char('@'));
1944 new.set_raw(40, 12, Cell::from_char('#'));
1945 new.set_raw(70, 20, Cell::from_char('$'));
1946 new.set_raw(0, 23, Cell::from_char('%'));
1947
1948 let diff = BufferDiff::compute(&old, &new);
1949 let checksum = fnv1a_hash(diff.changes());
1950
1951 let diff2 = BufferDiff::compute(&old, &new);
1953 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1954
1955 assert!(
1957 diff.len() <= 5,
1958 "sparse update should have <=5 changes, got {}",
1959 diff.len()
1960 );
1961 assert!(
1962 diff.len() >= 3,
1963 "sparse update should have >=3 changes, got {}",
1964 diff.len()
1965 );
1966 }
1967
1968 #[test]
1973 fn e2e_random_scene_replay() {
1974 let width = 80u16;
1977 let height = 24u16;
1978 let base_seed: u64 = 0x5C3E_E3E1_A442u64;
1979
1980 let mut checksums = Vec::new();
1981
1982 for frame in 0..10u64 {
1983 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1984 let old = build_golden_scene(width, height, seed);
1985 let new = build_golden_scene(width, height, seed.wrapping_add(1));
1986
1987 let diff = BufferDiff::compute(&old, &new);
1988 let dirty_diff = BufferDiff::compute_dirty(&old, &new);
1989
1990 assert_eq!(
1992 diff.changes(),
1993 dirty_diff.changes(),
1994 "frame {frame}: dirty diff must match full diff"
1995 );
1996
1997 checksums.push(fnv1a_hash(diff.changes()));
1998 }
1999
2000 for frame in 0..10u64 {
2002 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2003 let old = build_golden_scene(width, height, seed);
2004 let new = build_golden_scene(width, height, seed.wrapping_add(1));
2005
2006 let diff = BufferDiff::compute(&old, &new);
2007 assert_eq!(
2008 fnv1a_hash(diff.changes()),
2009 checksums[frame as usize],
2010 "frame {frame}: checksum mismatch on replay"
2011 );
2012 }
2013 }
2014
2015 #[test]
2020 fn perf_diff_microbench() {
2021 use std::time::Instant;
2022
2023 let scenarios: &[(u16, u16, &str, u64)] = &[
2024 (80, 24, "full_frame", 0xBE4C_0001u64),
2025 (80, 24, "sparse_update", 0xBE4C_0002u64),
2026 (120, 40, "full_frame", 0xBE4C_0003u64),
2027 (120, 40, "sparse_update", 0xBE4C_0004u64),
2028 ];
2029
2030 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2031 .ok()
2032 .and_then(|value| value.parse::<u32>().ok())
2033 .unwrap_or(50u32);
2034
2035 for &(width, height, scene_type, seed) in scenarios {
2036 let old = Buffer::new(width, height);
2037 let new = match scene_type {
2038 "full_frame" => build_golden_scene(width, height, seed),
2039 "sparse_update" => {
2040 let mut buf = old.clone();
2041 buf.set_raw(10, 5, Cell::from_char('!'));
2042 buf.set_raw(40, 12, Cell::from_char('#'));
2043 buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
2044 buf
2045 }
2046 _ => unreachable!(),
2047 };
2048
2049 let mut times_us = Vec::with_capacity(iterations as usize);
2050 let mut last_changes = 0usize;
2051 let mut last_runs = 0usize;
2052 let mut last_checksum = 0u64;
2053
2054 for _ in 0..iterations {
2055 let start = Instant::now();
2056 let diff = BufferDiff::compute(&old, &new);
2057 let runs = diff.runs();
2058 let elapsed = start.elapsed();
2059
2060 last_changes = diff.len();
2061 last_runs = runs.len();
2062 last_checksum = fnv1a_hash(diff.changes());
2063 times_us.push(elapsed.as_micros() as u64);
2064 }
2065
2066 times_us.sort();
2067 let len = times_us.len();
2068 let p50 = times_us[len / 2];
2069 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2070 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
2071
2072 eprintln!(
2074 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
2075 seed,
2076 width,
2077 height,
2078 scene_type,
2079 last_changes,
2080 last_runs,
2081 p50,
2082 p95,
2083 p99,
2084 last_checksum
2085 );
2086
2087 let budget_us = match (width, height) {
2090 (80, 24) => 500, (120, 40) => 1000, _ => 2000,
2093 };
2094
2095 for _ in 0..3 {
2097 let diff = BufferDiff::compute(&old, &new);
2098 assert_eq!(
2099 fnv1a_hash(diff.changes()),
2100 last_checksum,
2101 "diff must be deterministic"
2102 );
2103 }
2104
2105 if p95 > budget_us {
2107 eprintln!(
2108 "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
2109 );
2110 }
2111 }
2112 }
2113
2114 #[test]
2119 fn perf_dirty_diff_large_screen_regression() {
2120 use std::time::Instant;
2121
2122 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2123 .ok()
2124 .and_then(|value| value.parse::<u32>().ok())
2125 .unwrap_or(50u32);
2126
2127 let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
2128 .ok()
2129 .and_then(|value| value.parse::<f64>().ok())
2130 .unwrap_or(2.0);
2131
2132 let cases: &[(u16, u16, &str, f64)] = &[
2133 (200, 60, "sparse_5pct", 5.0),
2134 (240, 80, "sparse_5pct", 5.0),
2135 (200, 60, "single_row", 0.0),
2136 (240, 80, "single_row", 0.0),
2137 ];
2138
2139 for &(width, height, pattern, pct) in cases {
2140 let old = Buffer::new(width, height);
2141 let mut new = old.clone();
2142
2143 if pattern == "single_row" {
2144 for x in 0..width {
2145 new.set_raw(x, 0, Cell::from_char('X'));
2146 }
2147 } else {
2148 let total = width as usize * height as usize;
2149 let to_change = ((total as f64) * pct / 100.0) as usize;
2150 for i in 0..to_change {
2151 let x = (i * 7 + 3) as u16 % width;
2152 let y = (i * 11 + 5) as u16 % height;
2153 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2154 new.set_raw(x, y, Cell::from_char(ch));
2155 }
2156 }
2157
2158 let full = BufferDiff::compute(&old, &new);
2160 let dirty = BufferDiff::compute_dirty(&old, &new);
2161 let change_count = full.len();
2162 let dirty_rows = new.dirty_row_count();
2163 assert_eq!(
2164 full.changes(),
2165 dirty.changes(),
2166 "dirty diff must match full diff for {width}x{height} {pattern}"
2167 );
2168
2169 let mut full_times = Vec::with_capacity(iterations as usize);
2170 let mut dirty_times = Vec::with_capacity(iterations as usize);
2171
2172 for _ in 0..iterations {
2173 let start = Instant::now();
2174 let diff = BufferDiff::compute(&old, &new);
2175 std::hint::black_box(diff.len());
2176 full_times.push(start.elapsed().as_micros() as u64);
2177
2178 let start = Instant::now();
2179 let diff = BufferDiff::compute_dirty(&old, &new);
2180 std::hint::black_box(diff.len());
2181 dirty_times.push(start.elapsed().as_micros() as u64);
2182 }
2183
2184 full_times.sort();
2185 dirty_times.sort();
2186
2187 let len = full_times.len();
2188 let p50_idx = len / 2;
2189 let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2190
2191 let full_p50 = full_times[p50_idx];
2192 let full_p95 = full_times[p95_idx];
2193 let dirty_p50 = dirty_times[p50_idx];
2194 let dirty_p95 = dirty_times[p95_idx];
2195
2196 let denom = full_p50.max(1) as f64;
2197 let ratio = dirty_p50 as f64 / denom;
2198
2199 eprintln!(
2200 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"event\":\"diff_regression\",\"width\":{},\"height\":{},\"pattern\":\"{}\",\"iterations\":{},\"changes\":{},\"dirty_rows\":{},\"full_p50_us\":{},\"full_p95_us\":{},\"dirty_p50_us\":{},\"dirty_p95_us\":{},\"slowdown_ratio\":{:.3},\"max_slowdown\":{}}}",
2201 width,
2202 height,
2203 pattern,
2204 iterations,
2205 change_count,
2206 dirty_rows,
2207 full_p50,
2208 full_p95,
2209 dirty_p50,
2210 dirty_p95,
2211 ratio,
2212 max_slowdown
2213 );
2214
2215 assert!(
2216 ratio <= max_slowdown,
2217 "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2218 );
2219 }
2220 }
2221
2222 #[test]
2223 fn tile_diff_matches_compute_for_sparse_tiles() {
2224 let width = 200;
2225 let height = 60;
2226 let old = Buffer::new(width, height);
2227 let mut new = old.clone();
2228
2229 new.clear_dirty();
2230 for x in 0..10u16 {
2231 new.set_raw(x, 0, Cell::from_char('X'));
2232 }
2233
2234 let full = BufferDiff::compute(&old, &new);
2235 let dirty = BufferDiff::compute_dirty(&old, &new);
2236
2237 assert_eq!(full.changes(), dirty.changes());
2238 let stats = dirty
2239 .last_tile_stats()
2240 .expect("tile stats should be recorded");
2241 assert!(
2242 stats.fallback.is_none(),
2243 "tile path should be used for sparse tiles"
2244 );
2245 }
2246
2247 fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2248 let mut buffer = Buffer::new(width, height);
2249 buffer.clear_dirty();
2250 for &(x, y, ch) in changes {
2251 buffer.set_raw(x, y, Cell::from_char(ch));
2252 }
2253 buffer
2254 }
2255
2256 fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2257 let mut diff = BufferDiff::new();
2258 *diff.tile_config_mut() = config;
2259 diff.compute_dirty_into(old, new);
2260 diff.last_tile_stats()
2261 .expect("tile stats should be recorded")
2262 }
2263
2264 #[test]
2265 fn tile_fallback_disabled_when_config_off() {
2266 let width = 64;
2267 let height = 32;
2268 let old = Buffer::new(width, height);
2269 let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2270
2271 let config = TileDiffConfig {
2272 enabled: false,
2273 min_cells_for_tiles: 0,
2274 dense_cell_ratio: 1.1,
2275 dense_tile_ratio: 1.1,
2276 max_tiles: usize::MAX,
2277 ..Default::default()
2278 };
2279
2280 let stats = tile_stats_for_config(&old, &new, config);
2281 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2282 }
2283
2284 #[test]
2285 fn tile_fallback_small_screen_when_below_threshold() {
2286 let width = 64;
2287 let height = 32;
2288 let old = Buffer::new(width, height);
2289 let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2290
2291 let config = TileDiffConfig {
2292 enabled: true,
2293 min_cells_for_tiles: width as usize * height as usize + 1,
2294 dense_cell_ratio: 1.1,
2295 dense_tile_ratio: 1.1,
2296 max_tiles: usize::MAX,
2297 ..Default::default()
2298 };
2299
2300 let stats = tile_stats_for_config(&old, &new, config);
2301 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2302 }
2303
2304 #[test]
2305 fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2306 let width = 64;
2307 let height = 64;
2308 let old = Buffer::new(width, height);
2309 let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2310
2311 let config = TileDiffConfig {
2312 enabled: true,
2313 tile_w: 8,
2314 tile_h: 8,
2315 skip_clean_rows: true,
2316 min_cells_for_tiles: 0,
2317 dense_cell_ratio: 1.1,
2318 dense_tile_ratio: 1.1,
2319 max_tiles: 4,
2320 };
2321
2322 let stats = tile_stats_for_config(&old, &new, config);
2323 assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2324 }
2325
2326 #[test]
2327 fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2328 let width = 64;
2329 let height = 32;
2330 let old = Buffer::new(width, height);
2331 let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2332
2333 let config = TileDiffConfig {
2334 enabled: true,
2335 tile_w: 8,
2336 tile_h: 8,
2337 skip_clean_rows: true,
2338 min_cells_for_tiles: 0,
2339 dense_cell_ratio: 1.1,
2340 dense_tile_ratio: 0.05,
2341 max_tiles: usize::MAX / 4,
2342 };
2343
2344 let stats = tile_stats_for_config(&old, &new, config);
2345 assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2346 }
2347
2348 #[test]
2349 fn tile_builder_skips_clean_rows_when_enabled() {
2350 let width = 200;
2351 let height = 60;
2352 let old = Buffer::new(width, height);
2353 let mut new = old.clone();
2354 new.clear_dirty();
2355 new.set_raw(3, 0, Cell::from_char('X'));
2356 new.set_raw(4, 10, Cell::from_char('Y'));
2357
2358 let dirty_rows = new.dirty_row_count().max(1);
2359 let total_cells = width as usize * height as usize;
2360 let expected_skip_cells = dirty_rows * width as usize;
2361
2362 let base = TileDiffConfig {
2363 enabled: true,
2364 tile_w: 16,
2365 tile_h: 8,
2366 min_cells_for_tiles: 0,
2367 dense_cell_ratio: 1.1,
2368 dense_tile_ratio: 1.1,
2369 max_tiles: usize::MAX,
2370 ..Default::default()
2371 };
2372 let config_full = TileDiffConfig {
2373 skip_clean_rows: false,
2374 ..base.clone()
2375 };
2376 let config_skip = TileDiffConfig {
2377 skip_clean_rows: true,
2378 ..base
2379 };
2380
2381 let stats_full = tile_stats_for_config(&old, &new, config_full);
2382 let stats_skip = tile_stats_for_config(&old, &new, config_skip);
2383
2384 assert_eq!(stats_full.sat_build_cells, total_cells);
2385 assert_eq!(stats_skip.sat_build_cells, expected_skip_cells);
2386 assert!(stats_skip.sat_build_cells < stats_full.sat_build_cells);
2387 }
2388
2389 fn lcg_next(state: &mut u64) -> u64 {
2390 *state = state
2391 .wrapping_mul(6364136223846793005)
2392 .wrapping_add(1442695040888963407);
2393 *state
2394 }
2395
2396 fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2397 let width = buf.width().max(1) as u64;
2398 let height = buf.height().max(1) as u64;
2399 let mut state = seed;
2400 for i in 0..count {
2401 let v = lcg_next(&mut state);
2402 let x = (v % width) as u16;
2403 let y = ((v >> 32) % height) as u16;
2404 let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2405 buf.set_raw(x, y, Cell::from_char(ch));
2406 }
2407 }
2408
2409 fn tile_diag(stats: &TileDiffStats) -> String {
2410 let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2411 format!(
2412 "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2413 stats.dirty_tiles,
2414 stats.skipped_tiles,
2415 stats.dirty_cells,
2416 stats.dirty_tile_ratio,
2417 stats.dirty_cell_ratio,
2418 stats.scanned_tiles,
2419 stats.fallback
2420 )
2421 }
2422
2423 fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2424 let mut diff = BufferDiff::new();
2425 {
2426 let config = diff.tile_config_mut();
2427 config.enabled = true;
2428 config.tile_w = 8;
2429 config.tile_h = 8;
2430 config.min_cells_for_tiles = 0;
2431 config.dense_cell_ratio = 1.1;
2432 config.dense_tile_ratio = 1.1;
2433 config.max_tiles = usize::MAX / 4;
2434 }
2435 diff.compute_dirty_into(old, new);
2436 let stats = diff
2437 .last_tile_stats()
2438 .expect("tile stats should be recorded");
2439 (diff, stats)
2440 }
2441
2442 fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2443 let full = BufferDiff::compute(old, new);
2444 let (dirty, stats) = diff_with_forced_tiles(old, new);
2445 let diag = tile_diag(&stats);
2446 assert!(
2447 stats.fallback.is_none(),
2448 "tile diff fallback ({label}) {w}x{h}: {diag}",
2449 w = old.width(),
2450 h = old.height()
2451 );
2452 assert!(
2453 full.changes() == dirty.changes(),
2454 "tile diff mismatch ({label}) {w}x{h}: {diag}",
2455 w = old.width(),
2456 h = old.height()
2457 );
2458 }
2459
2460 #[test]
2461 fn tile_diff_equivalence_small_and_odd_sizes() {
2462 let cases: &[(u16, u16, usize)] = &[
2463 (1, 1, 1),
2464 (2, 1, 1),
2465 (1, 2, 1),
2466 (5, 3, 4),
2467 (7, 13, 12),
2468 (15, 9, 20),
2469 (31, 5, 12),
2470 ];
2471
2472 for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2473 let old = Buffer::new(width, height);
2474 let mut new = old.clone();
2475 new.clear_dirty();
2476 apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2477 assert_tile_diff_equivalence(&old, &new, "small_odd");
2478 }
2479 }
2480
2481 #[test]
2482 fn tile_diff_equivalence_large_sparse_random() {
2483 let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2484 for (idx, &(width, height)) in cases.iter().enumerate() {
2485 let old = Buffer::new(width, height);
2486 let mut new = old.clone();
2487 new.clear_dirty();
2488 let total = width as usize * height as usize;
2489 let changes = (total / 100).max(1);
2490 apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2491 assert_tile_diff_equivalence(&old, &new, "large_sparse");
2492 }
2493 }
2494
2495 #[test]
2496 fn tile_diff_equivalence_row_and_full_buffer() {
2497 let width = 200u16;
2498 let height = 60u16;
2499 let old = Buffer::new(width, height);
2500
2501 let mut row = old.clone();
2502 row.clear_dirty();
2503 for x in 0..width {
2504 row.set_raw(x, 0, Cell::from_char('R'));
2505 }
2506 assert_tile_diff_equivalence(&old, &row, "single_row");
2507
2508 let mut full = old.clone();
2509 full.clear_dirty();
2510 for y in 0..height {
2511 for x in 0..width {
2512 full.set_raw(x, y, Cell::from_char('F'));
2513 }
2514 }
2515 assert_tile_diff_equivalence(&old, &full, "full_buffer");
2516 }
2517
2518 #[test]
2525 fn full_zero_width_returns_empty() {
2526 let diff = BufferDiff::full(0, 10);
2527 assert!(diff.is_empty());
2528 }
2529
2530 #[test]
2531 fn full_zero_height_returns_empty() {
2532 let diff = BufferDiff::full(10, 0);
2533 assert!(diff.is_empty());
2534 }
2535
2536 #[test]
2537 fn full_zero_both_returns_empty() {
2538 let diff = BufferDiff::full(0, 0);
2539 assert!(diff.is_empty());
2540 }
2541
2542 #[test]
2543 fn full_single_cell_has_one_change() {
2544 let diff = BufferDiff::full(1, 1);
2545 assert_eq!(diff.len(), 1);
2546 assert_eq!(diff.changes(), &[(0, 0)]);
2547 }
2548
2549 #[test]
2550 fn full_row_major_order() {
2551 let diff = BufferDiff::full(2, 3);
2552 assert_eq!(
2553 diff.changes(),
2554 &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]
2555 );
2556 }
2557
2558 #[test]
2561 fn runs_into_matches_runs_output() {
2562 let old = Buffer::new(20, 5);
2563 let mut new = Buffer::new(20, 5);
2564 new.set_raw(0, 0, Cell::from_char('A'));
2565 new.set_raw(1, 0, Cell::from_char('B'));
2566 new.set_raw(5, 2, Cell::from_char('C'));
2567 new.set_raw(10, 4, Cell::from_char('D'));
2568 new.set_raw(11, 4, Cell::from_char('E'));
2569
2570 let diff = BufferDiff::compute(&old, &new);
2571 let runs = diff.runs();
2572 let mut runs_buf = Vec::new();
2573 diff.runs_into(&mut runs_buf);
2574
2575 assert_eq!(runs, runs_buf);
2576 }
2577
2578 #[test]
2579 fn runs_into_clears_previous_content() {
2580 let diff = BufferDiff::new();
2581 let mut out = vec![ChangeRun::new(0, 0, 0)];
2582 diff.runs_into(&mut out);
2583 assert!(out.is_empty());
2584 }
2585
2586 #[test]
2587 fn runs_into_preserves_capacity() {
2588 let old = Buffer::new(10, 2);
2589 let mut new = Buffer::new(10, 2);
2590 new.set_raw(0, 0, Cell::from_char('A'));
2591 let diff = BufferDiff::compute(&old, &new);
2592
2593 let mut out = Vec::with_capacity(64);
2594 diff.runs_into(&mut out);
2595 assert_eq!(out.len(), 1);
2596 assert!(out.capacity() >= 64);
2597 }
2598
2599 #[test]
2602 fn change_run_fields_accessible() {
2603 let run = ChangeRun::new(7, 3, 10);
2604 assert_eq!(run.y, 7);
2605 assert_eq!(run.x0, 3);
2606 assert_eq!(run.x1, 10);
2607 assert_eq!(run.len(), 8);
2608 assert!(!run.is_empty());
2609 }
2610
2611 #[test]
2612 fn change_run_single_cell_not_empty() {
2613 let run = ChangeRun::new(0, 5, 5);
2614 assert_eq!(run.len(), 1);
2615 assert!(!run.is_empty());
2616 }
2617
2618 #[test]
2619 fn change_run_debug_format() {
2620 let run = ChangeRun::new(1, 2, 3);
2621 let dbg = format!("{:?}", run);
2622 assert!(dbg.contains("ChangeRun"), "Debug output: {dbg}");
2623 }
2624
2625 #[test]
2626 fn change_run_clone_copy_eq() {
2627 let run = ChangeRun::new(5, 10, 20);
2628 let copied = run; assert_eq!(run, copied);
2630 let cloned: ChangeRun = run; assert_eq!(run, cloned);
2632 }
2633
2634 #[test]
2635 fn change_run_ne() {
2636 let a = ChangeRun::new(0, 0, 5);
2637 let b = ChangeRun::new(0, 0, 6);
2638 assert_ne!(a, b);
2639 }
2640
2641 #[test]
2644 fn tile_diff_config_default_values() {
2645 let config = TileDiffConfig::default();
2646 assert!(config.enabled);
2647 assert_eq!(config.tile_w, 16);
2648 assert_eq!(config.tile_h, 8);
2649 assert!(config.skip_clean_rows);
2650 assert_eq!(config.min_cells_for_tiles, 12_000);
2651 assert!((config.dense_cell_ratio - 0.25).abs() < f64::EPSILON);
2652 assert!((config.dense_tile_ratio - 0.60).abs() < f64::EPSILON);
2653 assert_eq!(config.max_tiles, 4096);
2654 }
2655
2656 #[test]
2657 fn tile_diff_config_builder_methods() {
2658 let config = TileDiffConfig::default()
2659 .with_enabled(false)
2660 .with_tile_size(32, 32)
2661 .with_min_cells_for_tiles(500)
2662 .with_skip_clean_rows(false)
2663 .with_dense_cell_ratio(0.5)
2664 .with_dense_tile_ratio(0.8)
2665 .with_max_tiles(100);
2666
2667 assert!(!config.enabled);
2668 assert_eq!(config.tile_w, 32);
2669 assert_eq!(config.tile_h, 32);
2670 assert!(!config.skip_clean_rows);
2671 assert_eq!(config.min_cells_for_tiles, 500);
2672 assert!((config.dense_cell_ratio - 0.5).abs() < f64::EPSILON);
2673 assert!((config.dense_tile_ratio - 0.8).abs() < f64::EPSILON);
2674 assert_eq!(config.max_tiles, 100);
2675 }
2676
2677 #[test]
2678 fn tile_diff_config_debug_clone() {
2679 let config = TileDiffConfig::default();
2680 let dbg = format!("{:?}", config);
2681 assert!(dbg.contains("TileDiffConfig"), "Debug: {dbg}");
2682 let cloned = config.clone();
2683 assert_eq!(cloned.tile_w, 16);
2684 }
2685
2686 #[test]
2689 fn tile_diff_fallback_as_str_all_variants() {
2690 assert_eq!(TileDiffFallback::Disabled.as_str(), "disabled");
2691 assert_eq!(TileDiffFallback::SmallScreen.as_str(), "small_screen");
2692 assert_eq!(TileDiffFallback::DirtyAll.as_str(), "dirty_all");
2693 assert_eq!(TileDiffFallback::DenseCells.as_str(), "dense_cells");
2694 assert_eq!(TileDiffFallback::DenseTiles.as_str(), "dense_tiles");
2695 assert_eq!(TileDiffFallback::TooManyTiles.as_str(), "too_many_tiles");
2696 assert_eq!(TileDiffFallback::Overflow.as_str(), "overflow");
2697 }
2698
2699 #[test]
2700 fn tile_diff_fallback_traits() {
2701 let a = TileDiffFallback::Disabled;
2702 let b = a; assert_eq!(a, b);
2704 let c: TileDiffFallback = a; assert_eq!(a, c);
2706 assert_ne!(a, TileDiffFallback::Overflow);
2707 let dbg = format!("{:?}", a);
2708 assert!(dbg.contains("Disabled"), "Debug: {dbg}");
2709 }
2710
2711 #[test]
2714 fn tile_builder_dirty_all_fallback() {
2715 let mut builder = TileDiffBuilder::new();
2716 let config = TileDiffConfig::default().with_min_cells_for_tiles(0);
2717 let dirty_rows = vec![true; 10];
2718 let dirty_bits = vec![1u8; 200]; let input = TileDiffInput {
2721 width: 20,
2722 height: 10,
2723 dirty_rows: &dirty_rows,
2724 dirty_bits: &dirty_bits,
2725 dirty_cells: 200,
2726 dirty_all: true,
2727 };
2728
2729 let result = builder.build(&config, input);
2730 assert!(matches!(
2731 result,
2732 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DirtyAll)
2733 ));
2734 }
2735
2736 #[test]
2737 fn tile_builder_dense_cells_fallback() {
2738 let mut builder = TileDiffBuilder::new();
2740 let config = TileDiffConfig::default()
2741 .with_min_cells_for_tiles(0)
2742 .with_dense_cell_ratio(0.10); let w = 20u16;
2745 let h = 10u16;
2746 let total = (w as usize) * (h as usize);
2747 let dirty_count = total / 10; let dirty_rows = vec![true; h as usize];
2750 let dirty_bits = vec![0u8; total];
2751
2752 let input = TileDiffInput {
2753 width: w,
2754 height: h,
2755 dirty_rows: &dirty_rows,
2756 dirty_bits: &dirty_bits,
2757 dirty_cells: dirty_count,
2758 dirty_all: false,
2759 };
2760
2761 let result = builder.build(&config, input);
2762 assert!(matches!(
2763 result,
2764 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
2765 ));
2766 }
2767
2768 #[test]
2769 fn tile_builder_reuse_across_builds() {
2770 let mut builder = TileDiffBuilder::new();
2771 let config = TileDiffConfig::default()
2772 .with_min_cells_for_tiles(0)
2773 .with_dense_tile_ratio(1.1)
2774 .with_dense_cell_ratio(1.1)
2775 .with_max_tiles(usize::MAX / 4);
2776
2777 let dirty_rows_1 = vec![true; 10];
2779 let mut dirty_bits_1 = vec![0u8; 200];
2780 dirty_bits_1[0] = 1;
2781 let input_1 = TileDiffInput {
2782 width: 20,
2783 height: 10,
2784 dirty_rows: &dirty_rows_1,
2785 dirty_bits: &dirty_bits_1,
2786 dirty_cells: 1,
2787 dirty_all: false,
2788 };
2789 let result_1 = builder.build(&config, input_1);
2790 assert!(matches!(result_1, TileDiffBuild::UseTiles(_)));
2791
2792 let dirty_rows_2 = vec![true; 15];
2794 let mut dirty_bits_2 = vec![0u8; 450];
2795 dirty_bits_2[200] = 1;
2796 let input_2 = TileDiffInput {
2797 width: 30,
2798 height: 15,
2799 dirty_rows: &dirty_rows_2,
2800 dirty_bits: &dirty_bits_2,
2801 dirty_cells: 1,
2802 dirty_all: false,
2803 };
2804 let result_2 = builder.build(&config, input_2);
2805 assert!(matches!(result_2, TileDiffBuild::UseTiles(_)));
2806 }
2807
2808 #[test]
2809 fn tile_builder_default_matches_new() {
2810 let from_new = TileDiffBuilder::new();
2811 let from_default = TileDiffBuilder::default();
2812 assert_eq!(format!("{:?}", from_new), format!("{:?}", from_default));
2813 }
2814
2815 #[test]
2818 fn tile_params_total_tiles_and_cells() {
2819 let params = TileParams {
2820 width: 100,
2821 height: 50,
2822 tile_w: 16,
2823 tile_h: 8,
2824 tiles_x: 7, tiles_y: 7, };
2827 assert_eq!(params.total_tiles(), 49);
2828 assert_eq!(params.total_cells(), 5000);
2829 }
2830
2831 #[test]
2834 fn tile_diff_stats_from_builder() {
2835 let mut builder = TileDiffBuilder::new();
2836 let config = TileDiffConfig::default()
2837 .with_min_cells_for_tiles(0)
2838 .with_dense_tile_ratio(1.1)
2839 .with_dense_cell_ratio(1.1)
2840 .with_max_tiles(usize::MAX / 4);
2841
2842 let w = 32u16;
2843 let h = 16u16;
2844 let dirty_rows = vec![true; h as usize];
2845 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
2846 dirty_bits[0] = 1; let input = TileDiffInput {
2849 width: w,
2850 height: h,
2851 dirty_rows: &dirty_rows,
2852 dirty_bits: &dirty_bits,
2853 dirty_cells: 1,
2854 dirty_all: false,
2855 };
2856
2857 let result = builder.build(&config, input);
2858 match result {
2859 TileDiffBuild::UseTiles(plan) => {
2860 let stats = plan.stats;
2861 assert_eq!(stats.width, w);
2862 assert_eq!(stats.height, h);
2863 assert_eq!(stats.tile_w, 16);
2865 assert_eq!(stats.tile_h, 8);
2866 assert_eq!(stats.tiles_x, 2);
2868 assert_eq!(stats.tiles_y, 2);
2869 assert_eq!(stats.total_tiles, 4);
2870 assert_eq!(stats.dirty_cells, 1);
2871 assert_eq!(stats.dirty_tiles, 1);
2872 assert_eq!(stats.skipped_tiles, 3);
2873 assert!(stats.fallback.is_none());
2874 let copy = stats;
2876 assert_eq!(copy.width, stats.width);
2877 }
2878 _ => unreachable!("expected UseTiles"),
2879 }
2880 }
2881
2882 #[test]
2885 fn tile_size_clamped_to_min_8() {
2886 let mut builder = TileDiffBuilder::new();
2887 let config = TileDiffConfig {
2888 enabled: true,
2889 tile_w: 1, tile_h: 0, skip_clean_rows: false,
2892 min_cells_for_tiles: 0,
2893 dense_cell_ratio: 1.1,
2894 dense_tile_ratio: 1.1,
2895 max_tiles: usize::MAX / 4,
2896 };
2897
2898 let dirty_rows = vec![true; 16];
2899 let mut dirty_bits = vec![0u8; 256]; dirty_bits[0] = 1;
2901
2902 let input = TileDiffInput {
2903 width: 16,
2904 height: 16,
2905 dirty_rows: &dirty_rows,
2906 dirty_bits: &dirty_bits,
2907 dirty_cells: 1,
2908 dirty_all: false,
2909 };
2910
2911 let result = builder.build(&config, input);
2912 match result {
2913 TileDiffBuild::UseTiles(plan) => {
2914 assert_eq!(plan.stats.tile_w, 8);
2915 assert_eq!(plan.stats.tile_h, 8);
2916 }
2917 _ => unreachable!("expected UseTiles"),
2918 }
2919 }
2920
2921 #[test]
2922 fn tile_size_clamped_to_max_64() {
2923 let mut builder = TileDiffBuilder::new();
2924 let config = TileDiffConfig {
2925 enabled: true,
2926 tile_w: 255, tile_h: 100, skip_clean_rows: false,
2929 min_cells_for_tiles: 0,
2930 dense_cell_ratio: 1.1,
2931 dense_tile_ratio: 1.1,
2932 max_tiles: usize::MAX / 4,
2933 };
2934
2935 let dirty_rows = vec![true; 128];
2936 let mut dirty_bits = vec![0u8; 128 * 128];
2937 dirty_bits[0] = 1;
2938
2939 let input = TileDiffInput {
2940 width: 128,
2941 height: 128,
2942 dirty_rows: &dirty_rows,
2943 dirty_bits: &dirty_bits,
2944 dirty_cells: 1,
2945 dirty_all: false,
2946 };
2947
2948 let result = builder.build(&config, input);
2949 match result {
2950 TileDiffBuild::UseTiles(plan) => {
2951 assert_eq!(plan.stats.tile_w, 64);
2952 assert_eq!(plan.stats.tile_h, 64);
2953 }
2954 _ => unreachable!("expected UseTiles"),
2955 }
2956 }
2957
2958 #[test]
2961 fn buffer_diff_default_is_empty() {
2962 let diff: BufferDiff = Default::default();
2963 assert!(diff.is_empty());
2964 assert_eq!(diff.len(), 0);
2965 assert!(diff.last_tile_stats().is_none());
2966 }
2967
2968 #[test]
2969 fn buffer_diff_debug_format() {
2970 let diff = BufferDiff::new();
2971 let dbg = format!("{:?}", diff);
2972 assert!(dbg.contains("BufferDiff"), "Debug: {dbg}");
2973 }
2974
2975 #[test]
2976 fn buffer_diff_clone_preserves_changes() {
2977 let old = Buffer::new(5, 5);
2978 let mut new = Buffer::new(5, 5);
2979 new.set_raw(2, 3, Cell::from_char('X'));
2980 let diff = BufferDiff::compute(&old, &new);
2981 let cloned = diff.clone();
2982 assert_eq!(diff.changes(), cloned.changes());
2983 }
2984
2985 #[test]
2988 fn compute_into_replaces_previous_changes() {
2989 let mut diff = BufferDiff::new();
2990
2991 let old1 = Buffer::new(5, 5);
2992 let mut new1 = Buffer::new(5, 5);
2993 new1.set_raw(0, 0, Cell::from_char('A'));
2994 diff.compute_into(&old1, &new1);
2995 assert_eq!(diff.len(), 1);
2996
2997 let old2 = Buffer::new(3, 3);
2998 let mut new2 = Buffer::new(3, 3);
2999 new2.set_raw(1, 1, Cell::from_char('B'));
3000 new2.set_raw(2, 2, Cell::from_char('C'));
3001 diff.compute_into(&old2, &new2);
3002 assert_eq!(diff.len(), 2);
3003 assert_eq!(diff.changes(), &[(1, 1), (2, 2)]);
3004 }
3005
3006 #[test]
3007 fn compute_into_identical_clears() {
3008 let mut diff = BufferDiff::new();
3009 let old = Buffer::new(5, 5);
3010 let mut new = Buffer::new(5, 5);
3011 new.set_raw(0, 0, Cell::from_char('X'));
3012 diff.compute_into(&old, &new);
3013 assert_eq!(diff.len(), 1);
3014
3015 diff.compute_into(&old, &old);
3016 assert!(diff.is_empty());
3017 }
3018
3019 #[test]
3022 fn compute_dirty_into_no_dirty_rows() {
3023 let old = Buffer::new(10, 10);
3024 let mut new = old.clone();
3025 new.clear_dirty();
3026
3027 let mut diff = BufferDiff::new();
3028 diff.compute_dirty_into(&old, &new);
3029 assert!(diff.is_empty());
3030 }
3031
3032 #[test]
3033 fn compute_dirty_into_reuse() {
3034 let mut diff = BufferDiff::new();
3035
3036 let old = Buffer::new(10, 10);
3037 let mut new1 = old.clone();
3038 new1.clear_dirty();
3039 new1.set_raw(5, 5, Cell::from_char('X'));
3040 diff.compute_dirty_into(&old, &new1);
3041 assert_eq!(diff.len(), 1);
3042
3043 let mut new2 = old.clone();
3044 new2.clear_dirty();
3045 new2.set_raw(3, 3, Cell::from_char('Y'));
3046 new2.set_raw(7, 7, Cell::from_char('Z'));
3047 diff.compute_dirty_into(&old, &new2);
3048 assert_eq!(diff.len(), 2);
3049 assert_eq!(diff.changes(), &[(3, 3), (7, 7)]);
3050 }
3051
3052 #[test]
3055 fn last_tile_stats_none_after_compute_into() {
3056 let mut diff = BufferDiff::new();
3057 let old = Buffer::new(5, 5);
3058 let new = Buffer::new(5, 5);
3059 diff.compute_into(&old, &new);
3060 assert!(diff.last_tile_stats().is_none());
3061 }
3062
3063 #[test]
3064 fn last_tile_stats_some_after_compute_dirty_into() {
3065 let mut diff = BufferDiff::new();
3066 let old = Buffer::new(10, 10);
3067 let mut new = old.clone();
3068 new.set_raw(0, 0, Cell::from_char('A'));
3069 diff.compute_dirty_into(&old, &new);
3070 let stats = diff.last_tile_stats().expect("should have tile stats");
3072 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
3073 }
3074
3075 #[test]
3078 fn tile_config_mut_modifies_behavior() {
3079 let old = Buffer::new(200, 60);
3080 let mut new = old.clone();
3081 new.clear_dirty();
3082 new.set_raw(0, 0, Cell::from_char('X'));
3083
3084 let mut diff = BufferDiff::new();
3086 diff.compute_dirty_into(&old, &new);
3087 let stats = diff.last_tile_stats().expect("stats");
3088 assert!(
3089 stats.fallback.is_none(),
3090 "tiles should be active for 200x60"
3091 );
3092
3093 diff.tile_config_mut().enabled = false;
3095 diff.compute_dirty_into(&old, &new);
3096 let stats = diff.last_tile_stats().expect("stats");
3097 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
3098 }
3099
3100 #[test]
3103 fn row_scan_width_31_below_row_block_size() {
3104 let old = Buffer::new(31, 1);
3105 let mut new = Buffer::new(31, 1);
3106 new.set_raw(0, 0, Cell::from_char('A'));
3107 new.set_raw(15, 0, Cell::from_char('B'));
3108 new.set_raw(30, 0, Cell::from_char('C'));
3109
3110 let diff = BufferDiff::compute(&old, &new);
3111 assert_eq!(diff.len(), 3);
3112 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (30, 0)]);
3113 }
3114
3115 #[test]
3116 fn row_scan_width_32_exact_row_block_size() {
3117 let old = Buffer::new(32, 1);
3118 let mut new = Buffer::new(32, 1);
3119 new.set_raw(0, 0, Cell::from_char('A'));
3120 new.set_raw(15, 0, Cell::from_char('B'));
3121 new.set_raw(31, 0, Cell::from_char('C'));
3122
3123 let diff = BufferDiff::compute(&old, &new);
3124 assert_eq!(diff.len(), 3);
3125 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (31, 0)]);
3126 }
3127
3128 #[test]
3129 fn row_scan_width_33_one_past_row_block_size() {
3130 let old = Buffer::new(33, 1);
3131 let mut new = Buffer::new(33, 1);
3132 new.set_raw(0, 0, Cell::from_char('A'));
3133 new.set_raw(31, 0, Cell::from_char('B'));
3134 new.set_raw(32, 0, Cell::from_char('C'));
3135
3136 let diff = BufferDiff::compute(&old, &new);
3137 assert_eq!(diff.len(), 3);
3138 assert_eq!(diff.changes(), &[(0, 0), (31, 0), (32, 0)]);
3139 }
3140
3141 #[test]
3144 fn dense_cells_exact_threshold_triggers_fallback() {
3145 let mut builder = TileDiffBuilder::new();
3146 let w = 20u16;
3147 let h = 20u16;
3148 let total = (w as usize) * (h as usize); let dirty_count = total / 4; let config = TileDiffConfig {
3152 enabled: true,
3153 tile_w: 8,
3154 tile_h: 8,
3155 skip_clean_rows: false,
3156 min_cells_for_tiles: 0,
3157 dense_cell_ratio: 0.25,
3158 dense_tile_ratio: 1.1,
3159 max_tiles: usize::MAX / 4,
3160 };
3161
3162 let dirty_rows = vec![true; h as usize];
3163 let dirty_bits = vec![1u8; total];
3164
3165 let input = TileDiffInput {
3166 width: w,
3167 height: h,
3168 dirty_rows: &dirty_rows,
3169 dirty_bits: &dirty_bits,
3170 dirty_cells: dirty_count,
3171 dirty_all: false,
3172 };
3173
3174 let result = builder.build(&config, input);
3175 assert!(matches!(
3176 result,
3177 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
3178 ));
3179 }
3180
3181 #[test]
3182 fn dense_cells_just_below_threshold_passes() {
3183 let mut builder = TileDiffBuilder::new();
3184 let w = 20u16;
3185 let h = 20u16;
3186 let total = (w as usize) * (h as usize); let dirty_count = total / 4 - 1; let config = TileDiffConfig {
3190 enabled: true,
3191 tile_w: 8,
3192 tile_h: 8,
3193 skip_clean_rows: false,
3194 min_cells_for_tiles: 0,
3195 dense_cell_ratio: 0.25,
3196 dense_tile_ratio: 1.1,
3197 max_tiles: usize::MAX / 4,
3198 };
3199
3200 let dirty_rows = vec![true; h as usize];
3201 let mut dirty_bits = vec![0u8; total];
3202 for bit in dirty_bits.iter_mut().take(dirty_count) {
3203 *bit = 1;
3204 }
3205
3206 let input = TileDiffInput {
3207 width: w,
3208 height: h,
3209 dirty_rows: &dirty_rows,
3210 dirty_bits: &dirty_bits,
3211 dirty_cells: dirty_count,
3212 dirty_all: false,
3213 };
3214
3215 let result = builder.build(&config, input);
3216 match &result {
3217 TileDiffBuild::Fallback(stats) => {
3218 assert_ne!(
3219 stats.fallback,
3220 Some(TileDiffFallback::DenseCells),
3221 "should not trigger DenseCells below 25%: {:?}",
3222 stats.fallback
3223 );
3224 }
3225 TileDiffBuild::UseTiles(_) => { }
3226 }
3227 }
3228
3229 #[test]
3232 fn switch_between_compute_and_dirty() {
3233 let mut diff = BufferDiff::new();
3234 let old = Buffer::new(10, 10);
3235 let mut new = Buffer::new(10, 10);
3236 new.set_raw(3, 3, Cell::from_char('X'));
3237
3238 diff.compute_into(&old, &new);
3239 assert_eq!(diff.len(), 1);
3240 assert!(diff.last_tile_stats().is_none());
3241
3242 diff.compute_dirty_into(&old, &new);
3243 assert_eq!(diff.len(), 1);
3244 assert!(diff.last_tile_stats().is_some());
3245
3246 diff.compute_into(&old, &new);
3247 assert_eq!(diff.len(), 1);
3248 assert!(diff.last_tile_stats().is_none());
3249 }
3250
3251 #[test]
3254 #[should_panic(expected = "buffer heights must match")]
3255 fn compute_panics_on_height_mismatch() {
3256 let old = Buffer::new(5, 5);
3257 let new = Buffer::new(5, 4);
3258 let _ = BufferDiff::compute(&old, &new);
3259 }
3260
3261 #[test]
3262 #[should_panic(expected = "buffer widths must match")]
3263 fn compute_dirty_panics_on_width_mismatch() {
3264 let old = Buffer::new(5, 5);
3265 let new = Buffer::new(4, 5);
3266 let _ = BufferDiff::compute_dirty(&old, &new);
3267 }
3268
3269 #[test]
3270 #[should_panic(expected = "buffer heights must match")]
3271 fn compute_dirty_panics_on_height_mismatch() {
3272 let old = Buffer::new(5, 5);
3273 let new = Buffer::new(5, 4);
3274 let _ = BufferDiff::compute_dirty(&old, &new);
3275 }
3276
3277 #[test]
3280 fn tile_diff_input_debug_copy() {
3281 let dirty_rows = vec![true; 2];
3282 let dirty_bits = vec![0u8; 4];
3283 let input = TileDiffInput {
3284 width: 2,
3285 height: 2,
3286 dirty_rows: &dirty_rows,
3287 dirty_bits: &dirty_bits,
3288 dirty_cells: 0,
3289 dirty_all: false,
3290 };
3291 let dbg = format!("{:?}", input);
3292 assert!(dbg.contains("TileDiffInput"), "Debug: {dbg}");
3293 let copy = input; assert_eq!(copy.width, input.width);
3295 }
3296
3297 #[test]
3298 fn tile_diff_build_debug() {
3299 let mut builder = TileDiffBuilder::new();
3300 let config = TileDiffConfig::default();
3301 let dirty_rows = vec![true; 1];
3302 let dirty_bits = vec![0u8; 4];
3303 let input = TileDiffInput {
3304 width: 4,
3305 height: 1,
3306 dirty_rows: &dirty_rows,
3307 dirty_bits: &dirty_bits,
3308 dirty_cells: 0,
3309 dirty_all: false,
3310 };
3311 let result = builder.build(&config, input);
3312 let dbg = format!("{:?}", result);
3313 assert!(dbg.contains("Fallback"), "Debug: {dbg}");
3314 }
3315
3316 #[test]
3319 fn full_diff_runs_one_per_row() {
3320 let diff = BufferDiff::full(10, 3);
3321 let runs = diff.runs();
3322 assert_eq!(runs.len(), 3);
3323 for (i, run) in runs.iter().enumerate() {
3324 assert_eq!(run.y, i as u16);
3325 assert_eq!(run.x0, 0);
3326 assert_eq!(run.x1, 9);
3327 assert_eq!(run.len(), 10);
3328 }
3329 }
3330
3331 #[test]
3334 fn dirty_diff_skips_false_positive_rows() {
3335 let old = Buffer::new(10, 5);
3336 let mut new = old.clone();
3337 new.clear_dirty();
3338
3339 for y in 0..5u16 {
3341 new.set_raw(0, y, Cell::default());
3342 }
3343 new.set_raw(5, 1, Cell::from_char('A'));
3345 new.set_raw(7, 3, Cell::from_char('B'));
3346
3347 let diff = BufferDiff::compute_dirty(&old, &new);
3348 assert_eq!(diff.len(), 2);
3349 assert!(diff.changes().contains(&(5, 1)));
3350 assert!(diff.changes().contains(&(7, 3)));
3351 }
3352
3353 #[test]
3356 fn bg_only_change_detected() {
3357 let old = Buffer::new(5, 1);
3358 let mut new = Buffer::new(5, 1);
3359 new.set_raw(2, 0, Cell::default().with_bg(PackedRgba::rgb(0, 0, 255)));
3360
3361 let diff = BufferDiff::compute(&old, &new);
3362 assert_eq!(diff.len(), 1);
3363 assert_eq!(diff.changes(), &[(2, 0)]);
3364 }
3365
3366 #[test]
3369 fn changes_at_buffer_corners() {
3370 let w = 100u16;
3371 let h = 50u16;
3372 let old = Buffer::new(w, h);
3373 let mut new = Buffer::new(w, h);
3374 new.set_raw(0, 0, Cell::from_char('A'));
3375 new.set_raw(w - 1, h - 1, Cell::from_char('Z'));
3376
3377 let diff = BufferDiff::compute(&old, &new);
3378 assert_eq!(diff.len(), 2);
3379 assert_eq!(diff.changes()[0], (0, 0));
3380 assert_eq!(diff.changes()[1], (w - 1, h - 1));
3381
3382 let runs = diff.runs();
3383 assert_eq!(runs.len(), 2);
3384 assert_eq!(runs[0], ChangeRun::new(0, 0, 0));
3385 assert_eq!(runs[1], ChangeRun::new(h - 1, w - 1, w - 1));
3386 }
3387
3388 #[test]
3391 fn tile_params_debug_copy() {
3392 let params = TileParams {
3393 width: 80,
3394 height: 24,
3395 tile_w: 16,
3396 tile_h: 8,
3397 tiles_x: 5,
3398 tiles_y: 3,
3399 };
3400 let dbg = format!("{:?}", params);
3401 assert!(dbg.contains("TileParams"), "Debug: {dbg}");
3402 let copy = params; assert_eq!(copy.width, params.width);
3404 let cloned: TileParams = params; assert_eq!(cloned.tiles_x, params.tiles_x);
3406 }
3407
3408 #[test]
3411 fn tile_diff_stats_debug_copy() {
3412 let mut builder = TileDiffBuilder::new();
3413 let config = TileDiffConfig::default();
3414 let dirty_rows = vec![true; 1];
3415 let dirty_bits = vec![0u8; 1];
3416 let input = TileDiffInput {
3417 width: 1,
3418 height: 1,
3419 dirty_rows: &dirty_rows,
3420 dirty_bits: &dirty_bits,
3421 dirty_cells: 0,
3422 dirty_all: false,
3423 };
3424 let result = builder.build(&config, input);
3425 match result {
3426 TileDiffBuild::Fallback(stats) => {
3427 let dbg = format!("{:?}", stats);
3428 assert!(dbg.contains("TileDiffStats"), "Debug: {dbg}");
3429 let copy = stats;
3430 assert_eq!(copy.width, stats.width);
3431 let cloned: TileDiffStats = stats; assert_eq!(cloned.height, stats.height);
3433 }
3434 _ => unreachable!("expected Fallback for 1x1"),
3435 }
3436 }
3437
3438 #[test]
3443 fn compute_dirty_with_targeted_spans() {
3444 let width = 100u16;
3447 let height = 10u16;
3448 let old = Buffer::new(width, height);
3449 let mut new = old.clone();
3450 new.clear_dirty();
3451
3452 new.set_raw(10, 3, Cell::from_char('A'));
3454 new.set_raw(11, 3, Cell::from_char('B'));
3455 new.set_raw(50, 7, Cell::from_char('C'));
3456
3457 let full = BufferDiff::compute(&old, &new);
3458 let dirty = BufferDiff::compute_dirty(&old, &new);
3459
3460 assert_eq!(full.changes(), dirty.changes());
3461 assert_eq!(dirty.len(), 3);
3462 assert!(dirty.changes().contains(&(10, 3)));
3463 assert!(dirty.changes().contains(&(11, 3)));
3464 assert!(dirty.changes().contains(&(50, 7)));
3465 }
3466
3467 #[test]
3468 fn compute_dirty_spans_on_multiple_rows() {
3469 let width = 80u16;
3471 let height = 5u16;
3472 let old = Buffer::new(width, height);
3473 let mut new = old.clone();
3474 new.clear_dirty();
3475
3476 new.set_raw(0, 0, Cell::from_char('A'));
3478 new.set_raw(1, 0, Cell::from_char('B'));
3479 new.set_raw(40, 2, Cell::from_char('C'));
3481 new.set_raw(79, 4, Cell::from_char('D'));
3483
3484 let full = BufferDiff::compute(&old, &new);
3485 let dirty = BufferDiff::compute_dirty(&old, &new);
3486
3487 assert_eq!(full.changes(), dirty.changes());
3488 assert_eq!(dirty.len(), 4);
3489 }
3490
3491 #[test]
3492 fn compute_dirty_span_with_false_positive_row() {
3493 let old = Buffer::new(20, 3);
3495 let mut new = old.clone();
3496 new.clear_dirty();
3497
3498 new.set_raw(5, 1, Cell::default());
3500 new.set_raw(10, 2, Cell::from_char('X'));
3502
3503 let dirty = BufferDiff::compute_dirty(&old, &new);
3504 assert_eq!(dirty.len(), 1);
3505 assert_eq!(dirty.changes(), &[(10, 2)]);
3506 }
3507
3508 #[test]
3509 fn compute_dirty_many_spans_per_row() {
3510 let width = 200u16;
3512 let height = 1u16;
3513 let old = Buffer::new(width, height);
3514 let mut new = old.clone();
3515 new.clear_dirty();
3516
3517 let positions: Vec<u16> = vec![5, 20, 35, 50, 65, 80, 95, 110, 125, 140];
3519 for &x in &positions {
3520 new.set_raw(x, 0, Cell::from_char('X'));
3521 }
3522
3523 let full = BufferDiff::compute(&old, &new);
3524 let dirty = BufferDiff::compute_dirty(&old, &new);
3525
3526 assert_eq!(full.changes(), dirty.changes());
3527 assert_eq!(dirty.len(), positions.len());
3528 }
3529
3530 #[test]
3535 fn tile_diff_with_dirty_spans_matches_full() {
3536 let width = 200u16;
3538 let height = 60u16;
3539 let old = Buffer::new(width, height);
3540 let mut new = old.clone();
3541 new.clear_dirty();
3542
3543 new.set_raw(5, 2, Cell::from_char('A'));
3545 new.set_raw(100, 30, Cell::from_char('B'));
3546 new.set_raw(195, 55, Cell::from_char('C'));
3547
3548 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
3549 let full = BufferDiff::compute(&old, &new);
3550
3551 assert!(stats.fallback.is_none(), "tile path should be used");
3552 assert_eq!(full.changes(), dirty_diff.changes());
3553 assert_eq!(dirty_diff.len(), 3);
3554 }
3555
3556 #[test]
3557 fn tile_diff_with_spans_straddling_tile_boundary() {
3558 let width = 200u16;
3560 let height = 60u16;
3561 let old = Buffer::new(width, height);
3562 let mut new = old.clone();
3563 new.clear_dirty();
3564
3565 new.set_raw(6, 0, Cell::from_char('A'));
3567 new.set_raw(7, 0, Cell::from_char('B'));
3568 new.set_raw(8, 0, Cell::from_char('C'));
3569 new.set_raw(9, 0, Cell::from_char('D'));
3570
3571 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
3572 let full = BufferDiff::compute(&old, &new);
3573
3574 assert!(stats.fallback.is_none());
3575 assert_eq!(full.changes(), dirty_diff.changes());
3576 assert_eq!(dirty_diff.len(), 4);
3577 }
3578
3579 #[test]
3580 fn tile_diff_single_dirty_tile_skips_others() {
3581 let width = 200u16;
3583 let height = 60u16;
3584 let old = Buffer::new(width, height);
3585 let mut new = old.clone();
3586 new.clear_dirty();
3587
3588 new.set_raw(3, 3, Cell::from_char('X'));
3590
3591 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
3592
3593 assert!(stats.fallback.is_none());
3594 assert_eq!(dirty_diff.len(), 1);
3595 assert!(stats.skipped_tiles > 0, "should skip clean tiles");
3596 assert_eq!(stats.dirty_tiles, 1, "only one tile should be dirty");
3597 }
3598
3599 #[test]
3604 fn tile_diff_plan_fields_accessible() {
3605 let mut builder = TileDiffBuilder::new();
3606 let config = TileDiffConfig::default()
3607 .with_min_cells_for_tiles(0)
3608 .with_dense_tile_ratio(1.1)
3609 .with_dense_cell_ratio(1.1)
3610 .with_max_tiles(usize::MAX / 4);
3611
3612 let w = 32u16;
3613 let h = 16u16;
3614 let dirty_rows = vec![true; h as usize];
3615 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
3616 dirty_bits[0] = 1; dirty_bits[8 + 8 * w as usize] = 1; let input = TileDiffInput {
3621 width: w,
3622 height: h,
3623 dirty_rows: &dirty_rows,
3624 dirty_bits: &dirty_bits,
3625 dirty_cells: 2,
3626 dirty_all: false,
3627 };
3628
3629 let result = builder.build(&config, input);
3630 match result {
3631 TileDiffBuild::UseTiles(plan) => {
3632 assert_eq!(plan.params.width, w);
3634 assert_eq!(plan.params.height, h);
3635 assert!(plan.params.tile_w >= 8);
3636 assert!(plan.params.tile_h >= 8);
3637 assert!(plan.params.tiles_x > 0);
3638 assert!(plan.params.tiles_y > 0);
3639
3640 assert_eq!(plan.dirty_tiles.len(), plan.params.total_tiles());
3642 let dirty_count: usize = plan.dirty_tiles.iter().filter(|&&d| d).count();
3643 assert_eq!(dirty_count, plan.stats.dirty_tiles);
3644
3645 assert_eq!(plan.tile_counts.len(), plan.params.total_tiles());
3647
3648 let expected_sat_len = (plan.params.tiles_x + 1) * (plan.params.tiles_y + 1);
3650 assert_eq!(plan.sat.len(), expected_sat_len);
3651
3652 let sat_w = plan.params.tiles_x + 1;
3654 for tx in 0..sat_w {
3655 assert_eq!(plan.sat[tx], 0, "SAT top border should be 0");
3656 }
3657 for ty in 0..plan.params.tiles_y + 1 {
3658 assert_eq!(plan.sat[ty * sat_w], 0, "SAT left border should be 0");
3659 }
3660 }
3661 _ => unreachable!("expected UseTiles"),
3662 }
3663 }
3664
3665 #[test]
3670 fn dense_tiles_exact_threshold_triggers_fallback() {
3671 let mut builder = TileDiffBuilder::new();
3672 let w = 32u16;
3673 let h = 32u16;
3674 let total_cells = (w as usize) * (h as usize);
3678 let config = TileDiffConfig {
3679 enabled: true,
3680 tile_w: 8,
3681 tile_h: 8,
3682 skip_clean_rows: false,
3683 min_cells_for_tiles: 0,
3684 dense_cell_ratio: 1.1,
3685 dense_tile_ratio: 0.5,
3686 max_tiles: usize::MAX / 4,
3687 };
3688
3689 let dirty_rows = vec![true; h as usize];
3690 let mut dirty_bits = vec![0u8; total_cells];
3691 for tile_y in 0..2 {
3694 for tile_x in 0..4 {
3695 let x = tile_x * 8;
3696 let y = tile_y * 8;
3697 dirty_bits[y * w as usize + x] = 1;
3698 }
3699 }
3700
3701 let input = TileDiffInput {
3702 width: w,
3703 height: h,
3704 dirty_rows: &dirty_rows,
3705 dirty_bits: &dirty_bits,
3706 dirty_cells: 8,
3707 dirty_all: false,
3708 };
3709
3710 let result = builder.build(&config, input);
3711 assert!(
3712 matches!(
3713 result,
3714 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseTiles)
3715 ),
3716 "8 of 16 tiles dirty (50%) should trigger DenseTiles with threshold 0.5"
3717 );
3718 }
3719
3720 #[test]
3721 fn dense_tiles_just_below_threshold_passes() {
3722 let mut builder = TileDiffBuilder::new();
3723 let w = 32u16;
3724 let h = 32u16;
3725 let total_cells = (w as usize) * (h as usize);
3728 let config = TileDiffConfig {
3729 enabled: true,
3730 tile_w: 8,
3731 tile_h: 8,
3732 skip_clean_rows: false,
3733 min_cells_for_tiles: 0,
3734 dense_cell_ratio: 1.1,
3735 dense_tile_ratio: 0.5,
3736 max_tiles: usize::MAX / 4,
3737 };
3738
3739 let dirty_rows = vec![true; h as usize];
3740 let mut dirty_bits = vec![0u8; total_cells];
3741 for tile_idx in 0..7 {
3743 let tile_x = tile_idx % 4;
3744 let tile_y = tile_idx / 4;
3745 let x = tile_x * 8;
3746 let y = tile_y * 8;
3747 dirty_bits[y * w as usize + x] = 1;
3748 }
3749
3750 let input = TileDiffInput {
3751 width: w,
3752 height: h,
3753 dirty_rows: &dirty_rows,
3754 dirty_bits: &dirty_bits,
3755 dirty_cells: 7,
3756 dirty_all: false,
3757 };
3758
3759 let result = builder.build(&config, input);
3760 assert!(
3761 matches!(result, TileDiffBuild::UseTiles(_)),
3762 "7 of 16 tiles dirty (43.75%) should use tiles with threshold 0.5"
3763 );
3764 }
3765
3766 #[test]
3771 fn span_diagnostics_with_no_dirty_spans() {
3772 let mut buf = Buffer::new(20, 3);
3773 buf.clear_dirty();
3774 let diag = span_diagnostics(&buf);
3775 assert!(
3777 diag.contains("stats="),
3778 "diagnostics should include stats: {diag}"
3779 );
3780 }
3781
3782 #[test]
3783 fn span_diagnostics_with_dirty_cells() {
3784 let mut buf = Buffer::new(20, 3);
3785 buf.clear_dirty();
3786 buf.set_raw(5, 1, Cell::from_char('X'));
3787 let diag = span_diagnostics(&buf);
3788 assert!(
3789 diag.contains("stats="),
3790 "diagnostics should include stats: {diag}"
3791 );
3792 }
3793
3794 #[test]
3795 fn span_diagnostics_with_full_row() {
3796 let mut buf = Buffer::new(20, 2);
3797 buf.clear_dirty();
3798 for x in 0..20u16 {
3800 buf.set_raw(x, 0, Cell::from_char('X'));
3801 }
3802 let diag = span_diagnostics(&buf);
3803 assert!(
3805 diag.contains("stats="),
3806 "diagnostics should include stats: {diag}"
3807 );
3808 }
3809
3810 #[test]
3815 fn compute_dirty_matches_full_for_single_row_buffer() {
3816 let old = Buffer::new(50, 1);
3817 let mut new = old.clone();
3818 new.set_raw(25, 0, Cell::from_char('M'));
3819
3820 let full = BufferDiff::compute(&old, &new);
3821 let dirty = BufferDiff::compute_dirty(&old, &new);
3822
3823 assert_eq!(full.changes(), dirty.changes());
3824 }
3825
3826 #[test]
3827 fn compute_dirty_matches_full_for_single_column_buffer() {
3828 let old = Buffer::new(1, 50);
3829 let mut new = old.clone();
3830 new.set_raw(0, 25, Cell::from_char('M'));
3831
3832 let full = BufferDiff::compute(&old, &new);
3833 let dirty = BufferDiff::compute_dirty(&old, &new);
3834
3835 assert_eq!(full.changes(), dirty.changes());
3836 }
3837
3838 #[test]
3839 fn tile_config_chain_returns_same_type() {
3840 let config = TileDiffConfig::default()
3842 .with_enabled(true)
3843 .with_tile_size(16, 16)
3844 .with_min_cells_for_tiles(1000)
3845 .with_skip_clean_rows(true)
3846 .with_dense_cell_ratio(0.3)
3847 .with_dense_tile_ratio(0.7)
3848 .with_max_tiles(2048);
3849
3850 assert!(config.enabled);
3851 assert_eq!(config.tile_w, 16);
3852 assert_eq!(config.tile_h, 16);
3853 assert_eq!(config.min_cells_for_tiles, 1000);
3854 assert!(config.skip_clean_rows);
3855 assert!((config.dense_cell_ratio - 0.3).abs() < f64::EPSILON);
3856 assert!((config.dense_tile_ratio - 0.7).abs() < f64::EPSILON);
3857 assert_eq!(config.max_tiles, 2048);
3858 }
3859
3860 #[test]
3861 fn runs_into_reuse_across_multiple_diffs() {
3862 let mut runs_buf: Vec<ChangeRun> = Vec::new();
3864
3865 let old1 = Buffer::new(10, 2);
3867 let mut new1 = Buffer::new(10, 2);
3868 new1.set_raw(0, 0, Cell::from_char('A'));
3869 new1.set_raw(1, 0, Cell::from_char('B'));
3870 let diff1 = BufferDiff::compute(&old1, &new1);
3871 diff1.runs_into(&mut runs_buf);
3872 assert_eq!(runs_buf.len(), 1);
3873 assert_eq!(runs_buf[0], ChangeRun::new(0, 0, 1));
3874
3875 let old2 = Buffer::new(5, 3);
3877 let mut new2 = Buffer::new(5, 3);
3878 new2.set_raw(2, 1, Cell::from_char('X'));
3879 new2.set_raw(4, 2, Cell::from_char('Y'));
3880 let diff2 = BufferDiff::compute(&old2, &new2);
3881 diff2.runs_into(&mut runs_buf);
3882 assert_eq!(runs_buf.len(), 2);
3883 assert_eq!(runs_buf[0], ChangeRun::new(1, 2, 2));
3884 assert_eq!(runs_buf[1], ChangeRun::new(2, 4, 4));
3885 }
3886
3887 #[test]
3888 fn full_diff_zero_dimensions_runs_empty() {
3889 let diff_w0 = BufferDiff::full(0, 5);
3890 assert!(diff_w0.runs().is_empty());
3891
3892 let diff_h0 = BufferDiff::full(5, 0);
3893 assert!(diff_h0.runs().is_empty());
3894
3895 let diff_both = BufferDiff::full(0, 0);
3896 assert!(diff_both.runs().is_empty());
3897 }
3898
3899 #[test]
3900 fn change_run_max_u16_values() {
3901 let run = ChangeRun::new(u16::MAX, 1, u16::MAX);
3903 assert_eq!(run.y, u16::MAX);
3904 assert_eq!(run.x0, 1);
3905 assert_eq!(run.x1, u16::MAX);
3906 assert_eq!(run.len(), u16::MAX); let run2 = ChangeRun::new(u16::MAX, u16::MAX, u16::MAX);
3910 assert_eq!(run2.len(), 1);
3911 }
3912
3913 #[test]
3914 fn tile_diff_equivalence_adjacent_tiles() {
3915 let width = 200u16;
3917 let height = 60u16;
3918 let old = Buffer::new(width, height);
3919 let mut new = old.clone();
3920 new.clear_dirty();
3921
3922 new.set_raw(7, 0, Cell::from_char('A')); new.set_raw(8, 0, Cell::from_char('B')); new.set_raw(15, 0, Cell::from_char('C')); new.set_raw(16, 0, Cell::from_char('D')); assert_tile_diff_equivalence(&old, &new, "adjacent_tile_boundaries");
3930 }
3931}
3932
3933#[cfg(test)]
3934mod proptests {
3935 use super::*;
3936 use crate::cell::Cell;
3937 use ftui_core::geometry::Rect;
3938 use proptest::prelude::*;
3939
3940 #[test]
3942 fn diff_apply_produces_target() {
3943 proptest::proptest!(|(
3944 width in 5u16..50,
3945 height in 5u16..30,
3946 num_changes in 0usize..200,
3947 )| {
3948 let old = Buffer::new(width, height);
3950
3951 let mut new = old.clone();
3953 for i in 0..num_changes {
3954 let x = (i * 7 + 3) as u16 % width;
3955 let y = (i * 11 + 5) as u16 % height;
3956 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3957 new.set_raw(x, y, Cell::from_char(ch));
3958 }
3959
3960 let diff = BufferDiff::compute(&old, &new);
3962
3963 let mut result = old.clone();
3965 for (x, y) in diff.iter() {
3966 let cell = *new.get_unchecked(x, y);
3967 result.set_raw(x, y, cell);
3968 }
3969
3970 for y in 0..height {
3972 for x in 0..width {
3973 let result_cell = result.get_unchecked(x, y);
3974 let new_cell = new.get_unchecked(x, y);
3975 prop_assert!(
3976 result_cell.bits_eq(new_cell),
3977 "Mismatch at ({}, {})",
3978 x,
3979 y
3980 );
3981 }
3982 }
3983 });
3984 }
3985
3986 #[test]
3988 fn identical_buffers_empty_diff() {
3989 proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
3990 let buf = Buffer::new(width, height);
3991 let diff = BufferDiff::compute(&buf, &buf);
3992 prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
3993 });
3994 }
3995
3996 #[test]
3998 fn diff_contains_only_real_changes() {
3999 proptest::proptest!(|(
4000 width in 5u16..50,
4001 height in 5u16..30,
4002 num_changes in 0usize..100,
4003 )| {
4004 let old = Buffer::new(width, height);
4005 let mut new = old.clone();
4006
4007 for i in 0..num_changes {
4008 let x = (i * 7 + 3) as u16 % width;
4009 let y = (i * 11 + 5) as u16 % height;
4010 new.set_raw(x, y, Cell::from_char('X'));
4011 }
4012
4013 let diff = BufferDiff::compute(&old, &new);
4014
4015 for (x, y) in diff.iter() {
4017 let old_cell = old.get_unchecked(x, y);
4018 let new_cell = new.get_unchecked(x, y);
4019 prop_assert!(
4020 !old_cell.bits_eq(new_cell),
4021 "Diff includes unchanged cell at ({}, {})",
4022 x,
4023 y
4024 );
4025 }
4026 });
4027 }
4028
4029 #[test]
4031 fn runs_are_contiguous() {
4032 proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
4033 let old = Buffer::new(width, height);
4034 let mut new = old.clone();
4035
4036 for y in 0..height.min(5) {
4038 for x in 0..width.min(10) {
4039 new.set_raw(x, y, Cell::from_char('#'));
4040 }
4041 }
4042
4043 let diff = BufferDiff::compute(&old, &new);
4044 let runs = diff.runs();
4045
4046 for run in runs {
4048 prop_assert!(run.x1 >= run.x0, "Run has invalid range");
4049 prop_assert!(!run.is_empty(), "Run should not be empty");
4050
4051 for x in run.x0..=run.x1 {
4053 let old_cell = old.get_unchecked(x, run.y);
4054 let new_cell = new.get_unchecked(x, run.y);
4055 prop_assert!(
4056 !old_cell.bits_eq(new_cell),
4057 "Run includes unchanged cell at ({}, {})",
4058 x,
4059 run.y
4060 );
4061 }
4062 }
4063 });
4064 }
4065
4066 #[test]
4068 fn runs_cover_all_changes() {
4069 proptest::proptest!(|(
4070 width in 10u16..60,
4071 height in 5u16..30,
4072 num_changes in 1usize..100,
4073 )| {
4074 let old = Buffer::new(width, height);
4075 let mut new = old.clone();
4076
4077 for i in 0..num_changes {
4078 let x = (i * 13 + 7) as u16 % width;
4079 let y = (i * 17 + 3) as u16 % height;
4080 new.set_raw(x, y, Cell::from_char('X'));
4081 }
4082
4083 let diff = BufferDiff::compute(&old, &new);
4084 let runs = diff.runs();
4085
4086 let mut run_cells: std::collections::HashSet<(u16, u16)> =
4088 std::collections::HashSet::new();
4089 for run in &runs {
4090 for x in run.x0..=run.x1 {
4091 let was_new = run_cells.insert((x, run.y));
4092 prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
4093 }
4094 }
4095
4096 for (x, y) in diff.iter() {
4098 prop_assert!(
4099 run_cells.contains(&(x, y)),
4100 "Change at ({}, {}) not covered by runs",
4101 x,
4102 y
4103 );
4104 }
4105
4106 prop_assert_eq!(
4107 run_cells.len(),
4108 diff.len(),
4109 "Run cell count should match diff change count"
4110 );
4111 });
4112 }
4113
4114 #[test]
4118 fn block_scan_matches_scalar() {
4119 proptest::proptest!(|(
4120 width in 1u16..200,
4121 height in 1u16..20,
4122 num_changes in 0usize..200,
4123 )| {
4124 use crate::cell::PackedRgba;
4125
4126 let old = Buffer::new(width, height);
4127 let mut new = Buffer::new(width, height);
4128
4129 for i in 0..num_changes {
4130 let x = (i * 13 + 7) as u16 % width;
4131 let y = (i * 17 + 3) as u16 % height;
4132 let fg = PackedRgba::rgb(
4133 ((i * 31) % 256) as u8,
4134 ((i * 47) % 256) as u8,
4135 ((i * 71) % 256) as u8,
4136 );
4137 new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4138 }
4139
4140 let diff = BufferDiff::compute(&old, &new);
4141
4142 let mut scalar_changes = Vec::new();
4144 for y in 0..height {
4145 for x in 0..width {
4146 let old_cell = old.get_unchecked(x, y);
4147 let new_cell = new.get_unchecked(x, y);
4148 if !old_cell.bits_eq(new_cell) {
4149 scalar_changes.push((x, y));
4150 }
4151 }
4152 }
4153
4154 prop_assert_eq!(
4155 diff.changes(),
4156 &scalar_changes[..],
4157 "Block scan should match scalar scan"
4158 );
4159 });
4160 }
4161
4162 #[test]
4168 fn property_diff_equivalence() {
4169 proptest::proptest!(|(
4170 width in 1u16..120,
4171 height in 1u16..40,
4172 num_changes in 0usize..300,
4173 )| {
4174 let old = Buffer::new(width, height);
4175 let mut new = Buffer::new(width, height);
4176
4177 for i in 0..num_changes {
4179 let x = (i * 13 + 7) as u16 % width;
4180 let y = (i * 17 + 3) as u16 % height;
4181 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4182 let fg = crate::cell::PackedRgba::rgb(
4183 ((i * 31) % 256) as u8,
4184 ((i * 47) % 256) as u8,
4185 ((i * 71) % 256) as u8,
4186 );
4187 new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
4188 }
4189
4190 let full = BufferDiff::compute(&old, &new);
4191 let dirty = BufferDiff::compute_dirty(&old, &new);
4192
4193 prop_assert_eq!(
4194 full.changes(),
4195 dirty.changes(),
4196 "dirty diff must match full diff (width={}, height={}, changes={})",
4197 width,
4198 height,
4199 num_changes
4200 );
4201
4202 let full_runs = full.runs();
4204 let dirty_runs = dirty.runs();
4205 prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
4206 for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
4207 prop_assert_eq!(fr, dr, "run mismatch");
4208 }
4209 });
4210 }
4211
4212 #[test]
4215 fn property_diff_equivalence_complex_spans() {
4216 proptest::proptest!(|(
4217 width in 10u16..100,
4218 height in 10u16..50,
4219 ops in proptest::collection::vec(
4220 prop_oneof![
4221 (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
4223 (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
4225 ],
4226 1..50
4227 )
4228 )| {
4229 let old = Buffer::new(width, height);
4230 let mut new = Buffer::new(width, height);
4231
4232 new.clear_dirty();
4234
4235 for (op_type, x, y, ch) in ops {
4236 let x = x % width;
4237 let y = y % height;
4238 let cell = Cell::from_char(ch);
4239
4240 match op_type {
4241 0 => new.set(x, y, cell),
4242 1 => {
4243 let w = ((x + 10).min(width) - x).max(1);
4245 let h = ((y + 5).min(height) - y).max(1);
4246 new.fill(Rect::new(x, y, w, h), cell);
4247 }
4248 _ => unreachable!(),
4249 }
4250 }
4251
4252 let full = BufferDiff::compute(&old, &new);
4253 let dirty = BufferDiff::compute_dirty(&old, &new);
4254
4255 prop_assert_eq!(
4256 full.changes(),
4257 dirty.changes(),
4258 "dirty diff (spans) must match full diff; {}",
4259 super::span_diagnostics(&new)
4260 );
4261 });
4262 }
4263
4264 #[test]
4272 fn diff_is_idempotent() {
4273 proptest::proptest!(|(
4274 width in 5u16..60,
4275 height in 5u16..30,
4276 num_changes in 0usize..100,
4277 )| {
4278 let mut buf_a = Buffer::new(width, height);
4279 let mut buf_b = Buffer::new(width, height);
4280
4281 for i in 0..num_changes {
4283 let x = (i * 13 + 7) as u16 % width;
4284 let y = (i * 17 + 3) as u16 % height;
4285 buf_b.set_raw(x, y, Cell::from_char('X'));
4286 }
4287
4288 let diff = BufferDiff::compute(&buf_a, &buf_b);
4290
4291 for (x, y) in diff.iter() {
4293 let cell = *buf_b.get_unchecked(x, y);
4294 buf_a.set_raw(x, y, cell);
4295 }
4296
4297 let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
4299 prop_assert!(
4300 diff_after_first.is_empty(),
4301 "After applying diff once, buffers should be identical (diff was {} changes)",
4302 diff_after_first.len()
4303 );
4304
4305 let before_second = buf_a.clone();
4307 for (x, y) in diff.iter() {
4308 let cell = *buf_b.get_unchecked(x, y);
4309 buf_a.set_raw(x, y, cell);
4310 }
4311
4312 let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
4314 prop_assert!(
4315 diff_after_second.is_empty(),
4316 "Second diff application should be a no-op"
4317 );
4318 });
4319 }
4320
4321 #[test]
4334 fn no_ghosting_after_clear() {
4335 proptest::proptest!(|(
4336 width in 10u16..80,
4337 height in 5u16..30,
4338 num_content_cells in 1usize..200,
4339 )| {
4340 let old = Buffer::new(width, height);
4342
4343 let mut new = Buffer::new(width, height);
4345 let mut expected_changes = std::collections::HashSet::new();
4346
4347 for i in 0..num_content_cells {
4348 let x = (i * 13 + 7) as u16 % width;
4349 let y = (i * 17 + 3) as u16 % height;
4350 new.set_raw(x, y, Cell::from_char('#'));
4351 expected_changes.insert((x, y));
4352 }
4353
4354 let diff = BufferDiff::compute(&old, &new);
4355
4356 for (x, y) in expected_changes {
4359 let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
4360 prop_assert!(
4361 in_diff,
4362 "Content cell at ({}, {}) missing from diff - would ghost",
4363 x,
4364 y
4365 );
4366 }
4367
4368 for (x, y) in diff.iter() {
4370 let old_cell = old.get_unchecked(x, y);
4371 let new_cell = new.get_unchecked(x, y);
4372 prop_assert!(
4373 !old_cell.bits_eq(new_cell),
4374 "Diff includes unchanged cell at ({}, {})",
4375 x,
4376 y
4377 );
4378 }
4379 });
4380 }
4381
4382 #[test]
4390 fn diff_changes_are_monotonic() {
4391 proptest::proptest!(|(
4392 width in 10u16..80,
4393 height in 5u16..30,
4394 num_changes in 1usize..200,
4395 )| {
4396 let old = Buffer::new(width, height);
4397 let mut new = old.clone();
4398
4399 for i in 0..num_changes {
4401 let x = (i * 37 + 11) as u16 % width;
4402 let y = (i * 53 + 7) as u16 % height;
4403 new.set_raw(x, y, Cell::from_char('M'));
4404 }
4405
4406 let diff = BufferDiff::compute(&old, &new);
4407 let changes: Vec<_> = diff.iter().collect();
4408
4409 for window in changes.windows(2) {
4411 let (x1, y1) = window[0];
4412 let (x2, y2) = window[1];
4413
4414 let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
4415 prop_assert!(
4416 is_monotonic,
4417 "Changes not monotonic: ({}, {}) should come before ({}, {})",
4418 x1,
4419 y1,
4420 x2,
4421 y2
4422 );
4423 }
4424 });
4425 }
4426}
4427
4428#[cfg(test)]
4429mod span_edge_cases {
4430 use super::*;
4431 use crate::cell::Cell;
4432 use proptest::prelude::*;
4433
4434 #[test]
4435 fn test_span_diff_u16_max_width() {
4436 let width = 65000;
4438 let height = 1;
4439 let old = Buffer::new(width, height);
4440 let mut new = Buffer::new(width, height);
4441
4442 new.clear_dirty();
4444
4445 new.set_raw(0, 0, Cell::from_char('A'));
4447 new.set_raw(32500, 0, Cell::from_char('B'));
4448 new.set_raw(64999, 0, Cell::from_char('C'));
4449
4450 let full = BufferDiff::compute(&old, &new);
4451 let dirty = BufferDiff::compute_dirty(&old, &new);
4452
4453 assert_eq!(full.changes(), dirty.changes());
4454 assert_eq!(full.len(), 3);
4455
4456 let changes = full.changes();
4458 assert!(changes.contains(&(0, 0)));
4459 assert!(changes.contains(&(32500, 0)));
4460 assert!(changes.contains(&(64999, 0)));
4461 }
4462
4463 #[test]
4464 fn test_span_full_row_dirty_overflow() {
4465 let width = 1000;
4466 let height = 1;
4467 let old = Buffer::new(width, height);
4468 let mut new = Buffer::new(width, height);
4469 new.clear_dirty(); for i in 0..70 {
4474 let x = (i * 10) as u16;
4475 new.set_raw(x, 0, Cell::from_char('X'));
4476 }
4477
4478 let stats = new.dirty_span_stats();
4480 assert!(
4481 stats.rows_full_dirty > 0,
4482 "Should have overflowed to full row"
4483 );
4484 assert_eq!(
4485 stats.rows_with_spans, 0,
4486 "Should have cleared spans on overflow"
4487 );
4488
4489 let full = BufferDiff::compute(&old, &new);
4490 let dirty = BufferDiff::compute_dirty(&old, &new);
4491
4492 assert_eq!(full.changes(), dirty.changes());
4493 assert_eq!(full.len(), 70);
4494 }
4495
4496 #[test]
4497 fn test_span_diff_empty_rows() {
4498 let width = 100;
4499 let height = 10;
4500 let old = Buffer::new(width, height);
4501 let mut new = Buffer::new(width, height);
4502 new.clear_dirty(); let dirty = BufferDiff::compute_dirty(&old, &new);
4506 assert!(dirty.is_empty());
4507 }
4508
4509 proptest! {
4510 #[test]
4511 fn property_span_diff_equivalence_large(
4512 width in 1000u16..5000,
4513 height in 10u16..50,
4514 changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
4515 ) {
4516 let w = width.min(2000);
4518 let h = height.min(50);
4519
4520 let old = Buffer::new(w, h);
4521 let mut new = Buffer::new(w, h);
4522 new.clear_dirty();
4523
4524 for (raw_x, raw_y) in changes {
4526 let x = raw_x % w;
4527 let y = raw_y % h;
4528 new.set_raw(x, y, Cell::from_char('Z'));
4529 }
4530
4531 let full = BufferDiff::compute(&old, &new);
4532 let dirty = BufferDiff::compute_dirty(&old, &new);
4533
4534 prop_assert_eq!(
4535 full.changes(),
4536 dirty.changes(),
4537 "Large buffer mismatch: w={}, h={}, {}",
4538 w,
4539 h,
4540 super::span_diagnostics(&new)
4541 );
4542 }
4543 }
4544}