1#![forbid(unsafe_code)]
2
3use crate::buffer::{Buffer, DirtySpan};
42use crate::cell::Cell;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum DiffSkipHint {
62 FullDiff,
64 SkipDiff,
66 NarrowToRows(Vec<u16>),
68}
69
70impl DiffSkipHint {
71 #[must_use]
73 pub fn skips_work(&self) -> bool {
74 !matches!(self, Self::FullDiff)
75 }
76
77 #[must_use]
79 pub fn label(&self) -> &'static str {
80 match self {
81 Self::FullDiff => "full-diff",
82 Self::SkipDiff => "skip-diff",
83 Self::NarrowToRows(_) => "narrow-to-rows",
84 }
85 }
86}
87
88const BLOCK_SIZE: usize = 4;
96
97const _: () = assert!(
99 core::mem::size_of::<Cell>() * BLOCK_SIZE == 64,
100 "BLOCK_SIZE * Cell must equal 64-byte cache line"
101);
102
103const _: () = assert!(
106 core::mem::align_of::<Cell>() >= 16,
107 "Cell alignment must be >= 16 for SIMD access"
108);
109
110const ROW_BLOCK_SIZE: usize = 32;
114
115#[repr(C, align(64))]
121#[allow(dead_code)]
122struct CacheLineBlock {
123 cells: [Cell; BLOCK_SIZE],
124}
125
126const _: () = assert!(
128 core::mem::size_of::<CacheLineBlock>() == 64,
129 "CacheLineBlock must be exactly 64 bytes"
130);
131
132#[cfg(test)]
133fn span_diagnostics(buf: &Buffer) -> String {
134 let stats = buf.dirty_span_stats();
135 if !buf.dirty_span_config().enabled {
136 return format!("dirty spans disabled; stats={stats:?}");
137 }
138 let mut rows = Vec::new();
139 for y in 0..buf.height() {
140 let Some(row) = buf.dirty_span_row(y) else {
141 continue;
142 };
143 if row.is_full() {
144 rows.push(format!("{y}:full"));
145 continue;
146 }
147 if row.spans().is_empty() {
148 continue;
149 }
150 let spans = row
151 .spans()
152 .iter()
153 .map(|span| format!("[{}, {})", span.x0, span.x1))
154 .collect::<Vec<_>>()
155 .join(",");
156 rows.push(format!("{y}:{spans}"));
157 }
158 format!("stats={stats:?} rows=[{}]", rows.join(" | "))
159}
160
161#[inline(always)]
168fn scan_row_changes_range(
169 old_row: &[Cell],
170 new_row: &[Cell],
171 y: u16,
172 x_offset: u16,
173 changes: &mut Vec<(u16, u16)>,
174) {
175 debug_assert_eq!(old_row.len(), new_row.len());
176 let len = old_row.len();
177 let blocks = len / BLOCK_SIZE;
178 let remainder = len % BLOCK_SIZE;
179
180 for block_idx in 0..blocks {
182 let base = block_idx * BLOCK_SIZE;
183 let base_x = x_offset + base as u16;
184 if cell_quad_bits_eq(old_row, new_row, base) {
185 continue;
186 }
187
188 for i in 0..BLOCK_SIZE {
191 if !old_row[base + i].bits_eq(&new_row[base + i]) {
192 changes.push((base_x + i as u16, y));
193 }
194 }
195 }
196
197 let rem_base = blocks * BLOCK_SIZE;
199 let rem_base_x = x_offset + rem_base as u16;
200 for i in 0..remainder {
201 if !old_row[rem_base + i].bits_eq(&new_row[rem_base + i]) {
202 changes.push((rem_base_x + i as u16, y));
203 }
204 }
205}
206
207#[inline(always)]
208fn cell_quad_bits_eq(old_row: &[Cell], new_row: &[Cell], base: usize) -> bool {
209 old_row[base].bits_eq(&new_row[base])
210 & old_row[base + 1].bits_eq(&new_row[base + 1])
211 & old_row[base + 2].bits_eq(&new_row[base + 2])
212 & old_row[base + 3].bits_eq(&new_row[base + 3])
213}
214
215#[inline]
220fn scan_row_changes(old_row: &[Cell], new_row: &[Cell], y: u16, changes: &mut Vec<(u16, u16)>) {
221 scan_row_changes_blockwise(old_row, new_row, y, changes);
222}
223
224#[inline]
227fn scan_row_changes_blockwise(
228 old_row: &[Cell],
229 new_row: &[Cell],
230 y: u16,
231 changes: &mut Vec<(u16, u16)>,
232) {
233 debug_assert_eq!(old_row.len(), new_row.len());
234 let len = old_row.len();
235 let blocks = len / ROW_BLOCK_SIZE;
236 let remainder = len % ROW_BLOCK_SIZE;
237
238 for block_idx in 0..blocks {
239 let base = block_idx * ROW_BLOCK_SIZE;
240 let old_block = &old_row[base..base + ROW_BLOCK_SIZE];
241 let new_block = &new_row[base..base + ROW_BLOCK_SIZE];
242 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
243 }
244
245 if remainder > 0 {
246 let base = blocks * ROW_BLOCK_SIZE;
247 let old_block = &old_row[base..base + remainder];
248 let new_block = &new_row[base..base + remainder];
249 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
250 }
251}
252
253#[inline]
255fn scan_row_changes_spans(
256 old_row: &[Cell],
257 new_row: &[Cell],
258 y: u16,
259 spans: &[DirtySpan],
260 changes: &mut Vec<(u16, u16)>,
261) {
262 for span in spans {
263 let start = span.x0 as usize;
264 let end = span.x1 as usize;
265 if start >= end || start >= old_row.len() {
266 continue;
267 }
268 let end = end.min(old_row.len());
269 let old_slice = &old_row[start..end];
270 let new_slice = &new_row[start..end];
271 if old_slice == new_slice {
272 continue;
273 }
274 scan_row_changes_range(old_slice, new_slice, y, span.x0, changes);
275 }
276}
277
278#[inline]
279fn scan_row_changes_range_if_needed(
280 old_row: &[Cell],
281 new_row: &[Cell],
282 y: u16,
283 start: usize,
284 end: usize,
285 changes: &mut Vec<(u16, u16)>,
286) {
287 if start >= end || start >= old_row.len() {
288 return;
289 }
290 let end = end.min(old_row.len());
291 let old_slice = &old_row[start..end];
292 let new_slice = &new_row[start..end];
293 if old_slice == new_slice {
294 return;
295 }
296 scan_row_changes_range(old_slice, new_slice, y, start as u16, changes);
297}
298
299#[inline]
300#[allow(clippy::too_many_arguments)]
301fn scan_row_tiles(
302 old_row: &[Cell],
303 new_row: &[Cell],
304 y: u16,
305 width: usize,
306 tile_w: usize,
307 tiles_x: usize,
308 tile_row_base: usize,
309 dirty_tiles: &[bool],
310 changes: &mut Vec<(u16, u16)>,
311) {
312 for tile_x in 0..tiles_x {
313 let tile_idx = tile_row_base + tile_x;
314 if !dirty_tiles[tile_idx] {
315 continue;
316 }
317 let start = tile_x * tile_w;
318 let end = ((tile_x + 1) * tile_w).min(width);
319 scan_row_changes_range_if_needed(old_row, new_row, y, start, end, changes);
320 }
321}
322
323#[inline]
324#[allow(clippy::too_many_arguments)]
325fn scan_row_tiles_spans(
326 old_row: &[Cell],
327 new_row: &[Cell],
328 y: u16,
329 width: usize,
330 tile_w: usize,
331 tiles_x: usize,
332 tile_row_base: usize,
333 dirty_tiles: &[bool],
334 spans: &[DirtySpan],
335 changes: &mut Vec<(u16, u16)>,
336) {
337 if spans.is_empty() {
338 return;
339 }
340 let max_x = width.saturating_sub(1);
341 for span in spans {
342 let span_start = span.x0 as usize;
343 let span_end_exclusive = span.x1 as usize;
344 if span_start >= span_end_exclusive || span_start > max_x {
345 continue;
346 }
347 let span_end = span_end_exclusive.saturating_sub(1).min(max_x);
348 let tile_x_start = span_start / tile_w;
349 let tile_x_end = span_end / tile_w;
350 for tile_x in tile_x_start..=tile_x_end {
351 if tile_x >= tiles_x {
352 break;
353 }
354 let tile_idx = tile_row_base + tile_x;
355 if !dirty_tiles[tile_idx] {
356 continue;
357 }
358 let tile_start = tile_x * tile_w;
359 let tile_end = ((tile_x + 1) * tile_w).min(width);
360 let seg_start = span_start.max(tile_start);
361 let seg_end = span_end.min(tile_end.saturating_sub(1));
362 if seg_start > seg_end {
363 continue;
364 }
365 scan_row_changes_range_if_needed(old_row, new_row, y, seg_start, seg_end + 1, changes);
366 }
367 }
368}
369
370const TILE_SIZE_MIN: u16 = 8;
375const TILE_SIZE_MAX: u16 = 64;
376
377#[inline]
378fn clamp_tile_size(value: u16) -> u16 {
379 value.clamp(TILE_SIZE_MIN, TILE_SIZE_MAX)
380}
381
382#[inline]
383fn div_ceil_usize(n: usize, d: usize) -> usize {
384 debug_assert!(d > 0);
385 n.div_ceil(d)
386}
387
388#[inline]
389fn accumulate_tile_counts_range(
390 tile_counts: &mut [u32],
391 dirty_row_bits: &[u8],
392 tile_row_base: usize,
393 tiles_x: usize,
394 tile_w: usize,
395 range: core::ops::Range<usize>,
396 scanned_cells: &mut usize,
397) -> Result<(), ()> {
398 let start = range.start;
399 let end = range.end;
400 if start >= end || start >= dirty_row_bits.len() {
401 return Ok(());
402 }
403
404 let end = end.min(dirty_row_bits.len());
405 *scanned_cells = scanned_cells.saturating_add(end.saturating_sub(start));
406
407 let tile_x_start = start / tile_w;
408 let tile_x_end = (end - 1) / tile_w;
409 for tile_x in tile_x_start..=tile_x_end {
410 if tile_x >= tiles_x {
411 break;
412 }
413
414 let seg_start = start.max(tile_x * tile_w);
415 let seg_end = end.min((tile_x + 1) * tile_w);
416 let count = dirty_row_bits[seg_start..seg_end]
417 .iter()
418 .filter(|&&bit| bit != 0)
419 .count();
420 if count == 0 {
421 continue;
422 }
423
424 let tile_idx = tile_row_base + tile_x;
425 match tile_counts[tile_idx].checked_add(count as u32) {
426 Some(value) => tile_counts[tile_idx] = value,
427 None => return Err(()),
428 }
429 }
430
431 Ok(())
432}
433
434#[derive(Debug, Clone)]
436pub struct TileDiffConfig {
437 pub enabled: bool,
439 pub tile_w: u16,
441 pub tile_h: u16,
443 pub skip_clean_rows: bool,
448 pub min_cells_for_tiles: usize,
450 pub dense_cell_ratio: f64,
452 pub dense_tile_ratio: f64,
454 pub max_tiles: usize,
456}
457
458impl Default for TileDiffConfig {
459 fn default() -> Self {
460 Self {
461 enabled: true,
462 tile_w: 16,
463 tile_h: 8,
464 skip_clean_rows: true,
465 min_cells_for_tiles: 12_000,
466 dense_cell_ratio: 0.25,
467 dense_tile_ratio: 0.60,
468 max_tiles: 4096,
469 }
470 }
471}
472
473impl TileDiffConfig {
474 #[must_use]
476 pub fn with_enabled(mut self, enabled: bool) -> Self {
477 self.enabled = enabled;
478 self
479 }
480
481 #[must_use]
483 pub fn with_tile_size(mut self, tile_w: u16, tile_h: u16) -> Self {
484 self.tile_w = tile_w;
485 self.tile_h = tile_h;
486 self
487 }
488
489 #[must_use]
491 pub fn with_min_cells_for_tiles(mut self, min_cells: usize) -> Self {
492 self.min_cells_for_tiles = min_cells;
493 self
494 }
495
496 #[must_use]
498 pub fn with_skip_clean_rows(mut self, skip: bool) -> Self {
499 self.skip_clean_rows = skip;
500 self
501 }
502
503 #[must_use]
505 pub fn with_dense_cell_ratio(mut self, ratio: f64) -> Self {
506 self.dense_cell_ratio = ratio;
507 self
508 }
509
510 #[must_use]
512 pub fn with_dense_tile_ratio(mut self, ratio: f64) -> Self {
513 self.dense_tile_ratio = ratio;
514 self
515 }
516
517 #[must_use]
519 pub fn with_max_tiles(mut self, max_tiles: usize) -> Self {
520 self.max_tiles = max_tiles;
521 self
522 }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum TileDiffFallback {
528 Disabled,
529 SmallScreen,
530 DirtyAll,
531 DenseCells,
532 DenseTiles,
533 TooManyTiles,
534 Overflow,
535}
536
537impl TileDiffFallback {
538 pub const fn as_str(self) -> &'static str {
539 match self {
540 Self::Disabled => "disabled",
541 Self::SmallScreen => "small_screen",
542 Self::DirtyAll => "dirty_all",
543 Self::DenseCells => "dense_cells",
544 Self::DenseTiles => "dense_tiles",
545 Self::TooManyTiles => "too_many_tiles",
546 Self::Overflow => "overflow",
547 }
548 }
549}
550
551#[derive(Debug, Clone, Copy)]
553pub struct TileParams {
554 pub width: u16,
555 pub height: u16,
556 pub tile_w: u16,
557 pub tile_h: u16,
558 pub tiles_x: usize,
559 pub tiles_y: usize,
560}
561
562impl TileParams {
563 #[inline]
564 pub fn total_tiles(self) -> usize {
565 self.tiles_x * self.tiles_y
566 }
567
568 #[inline]
569 pub fn total_cells(self) -> usize {
570 self.width as usize * self.height as usize
571 }
572}
573
574#[derive(Debug, Clone, Copy)]
576pub struct TileDiffStats {
577 pub width: u16,
578 pub height: u16,
579 pub tile_w: u16,
580 pub tile_h: u16,
581 pub tiles_x: usize,
582 pub tiles_y: usize,
583 pub total_tiles: usize,
584 pub dirty_cells: usize,
585 pub dirty_tiles: usize,
586 pub dirty_cell_ratio: f64,
587 pub dirty_tile_ratio: f64,
588 pub scanned_tiles: usize,
589 pub skipped_tiles: usize,
590 pub sat_build_cells: usize,
591 pub scan_cells_estimate: usize,
592 pub fallback: Option<TileDiffFallback>,
593}
594
595#[derive(Debug, Default, Clone)]
597#[must_use]
598pub struct TileDiffBuilder {
599 tile_counts: Vec<u32>,
600 sat: Vec<u32>,
601 dirty_tiles: Vec<bool>,
602}
603
604#[derive(Debug, Clone, Copy)]
606pub struct TileDiffInput<'a> {
607 pub width: u16,
608 pub height: u16,
609 pub dirty_rows: &'a [bool],
610 pub dirty_bits: &'a [u8],
611 pub dirty_cells: usize,
612 pub dirty_all: bool,
613}
614
615#[derive(Debug, Clone)]
617pub struct TileDiffPlan<'a> {
618 pub params: TileParams,
619 pub stats: TileDiffStats,
620 pub dirty_tiles: &'a [bool],
621 pub tile_counts: &'a [u32],
622 pub sat: &'a [u32],
623}
624
625#[derive(Debug, Clone)]
627pub enum TileDiffBuild<'a> {
628 UseTiles(TileDiffPlan<'a>),
629 Fallback(TileDiffStats),
630}
631
632impl TileDiffBuilder {
633 pub fn new() -> Self {
634 Self::default()
635 }
636
637 pub fn build<'a, 'b>(
638 &'a mut self,
639 config: &TileDiffConfig,
640 input: TileDiffInput<'b>,
641 ) -> TileDiffBuild<'a> {
642 let TileDiffInput {
643 width,
644 height,
645 dirty_rows,
646 dirty_bits,
647 dirty_cells,
648 dirty_all,
649 } = input;
650 let tile_w = clamp_tile_size(config.tile_w);
651 let tile_h = clamp_tile_size(config.tile_h);
652 let width_usize = width as usize;
653 let height_usize = height as usize;
654 let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
655 let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
656 let total_tiles = tiles_x * tiles_y;
657 let total_cells = width_usize * height_usize;
658 let dirty_cell_ratio = if total_cells == 0 {
659 0.0
660 } else {
661 dirty_cells as f64 / total_cells as f64
662 };
663
664 let mut stats = TileDiffStats {
665 width,
666 height,
667 tile_w,
668 tile_h,
669 tiles_x,
670 tiles_y,
671 total_tiles,
672 dirty_cells,
673 dirty_tiles: 0,
674 dirty_cell_ratio,
675 dirty_tile_ratio: 0.0,
676 scanned_tiles: 0,
677 skipped_tiles: total_tiles,
678 sat_build_cells: 0,
679 scan_cells_estimate: 0,
680 fallback: None,
681 };
682
683 if !config.enabled {
684 stats.fallback = Some(TileDiffFallback::Disabled);
685 return TileDiffBuild::Fallback(stats);
686 }
687
688 if total_cells < config.min_cells_for_tiles {
689 stats.fallback = Some(TileDiffFallback::SmallScreen);
690 return TileDiffBuild::Fallback(stats);
691 }
692
693 if dirty_all {
694 stats.fallback = Some(TileDiffFallback::DirtyAll);
695 return TileDiffBuild::Fallback(stats);
696 }
697
698 if dirty_cell_ratio >= config.dense_cell_ratio {
699 stats.fallback = Some(TileDiffFallback::DenseCells);
700 return TileDiffBuild::Fallback(stats);
701 }
702
703 if total_tiles > config.max_tiles {
704 stats.fallback = Some(TileDiffFallback::TooManyTiles);
705 return TileDiffBuild::Fallback(stats);
706 }
707
708 debug_assert_eq!(dirty_bits.len(), total_cells);
709 if dirty_bits.len() < total_cells {
710 stats.fallback = Some(TileDiffFallback::Overflow);
711 return TileDiffBuild::Fallback(stats);
712 }
713
714 self.tile_counts.resize(total_tiles, 0);
715 self.tile_counts.fill(0);
716 self.dirty_tiles.resize(total_tiles, false);
717 self.dirty_tiles.fill(false);
718
719 let tile_w_usize = tile_w as usize;
720 let tile_h_usize = tile_h as usize;
721 let mut overflow = false;
722 let mut scanned_cells = 0usize;
723
724 debug_assert_eq!(dirty_rows.len(), height_usize);
725 for y in 0..height_usize {
726 let row_dirty = if config.skip_clean_rows {
727 dirty_rows.get(y).copied().unwrap_or(true)
728 } else {
729 true
730 };
731 if !row_dirty {
732 continue;
733 }
734 scanned_cells = scanned_cells.saturating_add(width_usize);
735 let row_start = y * width_usize;
736 let tile_y = y / tile_h_usize;
737 for x in 0..width_usize {
738 let idx = row_start + x;
739 if dirty_bits[idx] == 0 {
740 continue;
741 }
742 let tile_x = x / tile_w_usize;
743 let tile_idx = tile_y * tiles_x + tile_x;
744 match self.tile_counts[tile_idx].checked_add(1) {
745 Some(value) => self.tile_counts[tile_idx] = value,
746 None => {
747 overflow = true;
748 break;
749 }
750 }
751 }
752 if overflow {
753 break;
754 }
755 }
756
757 if overflow {
758 stats.fallback = Some(TileDiffFallback::Overflow);
759 return TileDiffBuild::Fallback(stats);
760 }
761
762 let mut dirty_tiles = 0usize;
763 for (idx, count) in self.tile_counts.iter().enumerate() {
764 if *count > 0 {
765 self.dirty_tiles[idx] = true;
766 dirty_tiles += 1;
767 }
768 }
769
770 stats.dirty_tiles = dirty_tiles;
771 stats.dirty_tile_ratio = if total_tiles == 0 {
772 0.0
773 } else {
774 dirty_tiles as f64 / total_tiles as f64
775 };
776 stats.scanned_tiles = dirty_tiles;
777 stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
778 stats.sat_build_cells = scanned_cells;
779 stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
780
781 if stats.dirty_tile_ratio >= config.dense_tile_ratio {
782 stats.fallback = Some(TileDiffFallback::DenseTiles);
783 return TileDiffBuild::Fallback(stats);
784 }
785
786 let sat_w = tiles_x + 1;
787 let sat_h = tiles_y + 1;
788 let sat_len = sat_w * sat_h;
789 self.sat.resize(sat_len, 0);
790 self.sat.fill(0);
791
792 for ty in 0..tiles_y {
793 let row_base = (ty + 1) * sat_w;
794 let prev_base = ty * sat_w;
795 for tx in 0..tiles_x {
796 let count = self.tile_counts[ty * tiles_x + tx] as u64;
797 let above = self.sat[prev_base + tx + 1] as u64;
798 let left = self.sat[row_base + tx] as u64;
799 let diag = self.sat[prev_base + tx] as u64;
800 let value = count + above + left - diag;
801 if value > u32::MAX as u64 {
802 stats.fallback = Some(TileDiffFallback::Overflow);
803 return TileDiffBuild::Fallback(stats);
804 }
805 self.sat[row_base + tx + 1] = value as u32;
806 }
807 }
808
809 let params = TileParams {
810 width,
811 height,
812 tile_w,
813 tile_h,
814 tiles_x,
815 tiles_y,
816 };
817
818 TileDiffBuild::UseTiles(TileDiffPlan {
819 params,
820 stats,
821 dirty_tiles: &self.dirty_tiles,
822 tile_counts: &self.tile_counts,
823 sat: &self.sat,
824 })
825 }
826
827 fn build_from_buffer<'a>(
828 &'a mut self,
829 config: &TileDiffConfig,
830 buffer: &Buffer,
831 ) -> TileDiffBuild<'a> {
832 let width = buffer.width();
833 let height = buffer.height();
834 let dirty_rows = buffer.dirty_rows();
835 let dirty_bits = buffer.dirty_bits();
836 let dirty_cells = buffer.dirty_cell_count();
837 let dirty_all = buffer.dirty_all();
838
839 let tile_w = clamp_tile_size(config.tile_w);
840 let tile_h = clamp_tile_size(config.tile_h);
841 let width_usize = width as usize;
842 let height_usize = height as usize;
843 let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
844 let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
845 let total_tiles = tiles_x * tiles_y;
846 let total_cells = width_usize * height_usize;
847 let dirty_cell_ratio = if total_cells == 0 {
848 0.0
849 } else {
850 dirty_cells as f64 / total_cells as f64
851 };
852
853 let mut stats = TileDiffStats {
854 width,
855 height,
856 tile_w,
857 tile_h,
858 tiles_x,
859 tiles_y,
860 total_tiles,
861 dirty_cells,
862 dirty_tiles: 0,
863 dirty_cell_ratio,
864 dirty_tile_ratio: 0.0,
865 scanned_tiles: 0,
866 skipped_tiles: total_tiles,
867 sat_build_cells: 0,
868 scan_cells_estimate: 0,
869 fallback: None,
870 };
871
872 if !config.enabled {
873 stats.fallback = Some(TileDiffFallback::Disabled);
874 return TileDiffBuild::Fallback(stats);
875 }
876
877 if total_cells < config.min_cells_for_tiles {
878 stats.fallback = Some(TileDiffFallback::SmallScreen);
879 return TileDiffBuild::Fallback(stats);
880 }
881
882 if dirty_all {
883 stats.fallback = Some(TileDiffFallback::DirtyAll);
884 return TileDiffBuild::Fallback(stats);
885 }
886
887 if dirty_cell_ratio >= config.dense_cell_ratio {
888 stats.fallback = Some(TileDiffFallback::DenseCells);
889 return TileDiffBuild::Fallback(stats);
890 }
891
892 if total_tiles > config.max_tiles {
893 stats.fallback = Some(TileDiffFallback::TooManyTiles);
894 return TileDiffBuild::Fallback(stats);
895 }
896
897 debug_assert_eq!(dirty_bits.len(), total_cells);
898 if dirty_bits.len() < total_cells {
899 stats.fallback = Some(TileDiffFallback::Overflow);
900 return TileDiffBuild::Fallback(stats);
901 }
902
903 self.tile_counts.resize(total_tiles, 0);
904 self.tile_counts.fill(0);
905 self.dirty_tiles.resize(total_tiles, false);
906 self.dirty_tiles.fill(false);
907
908 let tile_w_usize = tile_w as usize;
909 let tile_h_usize = tile_h as usize;
910 let mut overflow = false;
911 let mut scanned_cells = 0usize;
912
913 debug_assert_eq!(dirty_rows.len(), height_usize);
914 for y in 0..height_usize {
915 let row_dirty = if config.skip_clean_rows {
916 dirty_rows.get(y).copied().unwrap_or(true)
917 } else {
918 true
919 };
920 if !row_dirty {
921 continue;
922 }
923
924 let row_start = y * width_usize;
925 let row_bits = &dirty_bits[row_start..row_start + width_usize];
926 let tile_y = y / tile_h_usize;
927 let tile_row_base = tile_y * tiles_x;
928
929 let row_result = match buffer.dirty_span_row(y as u16) {
930 Some(span_row) if !span_row.is_full() => {
931 let spans = span_row.spans();
932 if spans.is_empty() {
933 accumulate_tile_counts_range(
934 &mut self.tile_counts,
935 row_bits,
936 tile_row_base,
937 tiles_x,
938 tile_w_usize,
939 0..width_usize,
940 &mut scanned_cells,
941 )
942 } else {
943 let mut result = Ok(());
944 for span in spans {
945 result = accumulate_tile_counts_range(
946 &mut self.tile_counts,
947 row_bits,
948 tile_row_base,
949 tiles_x,
950 tile_w_usize,
951 span.x0 as usize..span.x1 as usize,
952 &mut scanned_cells,
953 );
954 if result.is_err() {
955 break;
956 }
957 }
958 result
959 }
960 }
961 _ => accumulate_tile_counts_range(
962 &mut self.tile_counts,
963 row_bits,
964 tile_row_base,
965 tiles_x,
966 tile_w_usize,
967 0..width_usize,
968 &mut scanned_cells,
969 ),
970 };
971
972 if row_result.is_err() {
973 overflow = true;
974 break;
975 }
976 }
977
978 if overflow {
979 stats.fallback = Some(TileDiffFallback::Overflow);
980 return TileDiffBuild::Fallback(stats);
981 }
982
983 let mut dirty_tiles = 0usize;
984 for (idx, count) in self.tile_counts.iter().enumerate() {
985 if *count > 0 {
986 self.dirty_tiles[idx] = true;
987 dirty_tiles += 1;
988 }
989 }
990
991 stats.dirty_tiles = dirty_tiles;
992 stats.dirty_tile_ratio = if total_tiles == 0 {
993 0.0
994 } else {
995 dirty_tiles as f64 / total_tiles as f64
996 };
997 stats.scanned_tiles = dirty_tiles;
998 stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
999 stats.sat_build_cells = scanned_cells;
1000 stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
1001
1002 if stats.dirty_tile_ratio >= config.dense_tile_ratio {
1003 stats.fallback = Some(TileDiffFallback::DenseTiles);
1004 return TileDiffBuild::Fallback(stats);
1005 }
1006
1007 let sat_w = tiles_x + 1;
1008 let sat_h = tiles_y + 1;
1009 let sat_len = sat_w * sat_h;
1010 self.sat.resize(sat_len, 0);
1011 self.sat.fill(0);
1012
1013 for ty in 0..tiles_y {
1014 let row_base = (ty + 1) * sat_w;
1015 let prev_base = ty * sat_w;
1016 for tx in 0..tiles_x {
1017 let count = self.tile_counts[ty * tiles_x + tx] as u64;
1018 let above = self.sat[prev_base + tx + 1] as u64;
1019 let left = self.sat[row_base + tx] as u64;
1020 let diag = self.sat[prev_base + tx] as u64;
1021 let value = count + above + left - diag;
1022 if value > u32::MAX as u64 {
1023 stats.fallback = Some(TileDiffFallback::Overflow);
1024 return TileDiffBuild::Fallback(stats);
1025 }
1026 self.sat[row_base + tx + 1] = value as u32;
1027 }
1028 }
1029
1030 let params = TileParams {
1031 width,
1032 height,
1033 tile_w,
1034 tile_h,
1035 tiles_x,
1036 tiles_y,
1037 };
1038
1039 TileDiffBuild::UseTiles(TileDiffPlan {
1040 params,
1041 stats,
1042 dirty_tiles: &self.dirty_tiles,
1043 tile_counts: &self.tile_counts,
1044 sat: &self.sat,
1045 })
1046 }
1047}
1048
1049#[inline]
1050fn reserve_changes_capacity(width: u16, height: u16, changes: &mut Vec<(u16, u16)>) {
1051 let estimated_changes = (width as usize * height as usize) / 20;
1053 let additional = estimated_changes.saturating_sub(changes.len());
1054 if additional > 0 {
1055 changes.reserve(additional);
1056 }
1057}
1058
1059fn compute_changes(old: &Buffer, new: &Buffer, changes: &mut Vec<(u16, u16)>) {
1060 #[cfg(feature = "tracing")]
1061 let _span = tracing::debug_span!("diff_compute", width = old.width(), height = old.height());
1062 #[cfg(feature = "tracing")]
1063 let _guard = _span.enter();
1064
1065 assert_eq!(old.width(), new.width(), "buffer widths must match");
1066 assert_eq!(old.height(), new.height(), "buffer heights must match");
1067
1068 let width = old.width();
1069 let height = old.height();
1070 let w = width as usize;
1071
1072 changes.clear();
1073 reserve_changes_capacity(width, height, changes);
1074
1075 let old_cells = old.cells();
1076 let new_cells = new.cells();
1077
1078 for y in 0..height {
1080 let row_start = y as usize * w;
1081 let old_row = &old_cells[row_start..row_start + w];
1082 let new_row = &new_cells[row_start..row_start + w];
1083
1084 scan_row_changes(old_row, new_row, y, changes);
1088 }
1089
1090 #[cfg(feature = "tracing")]
1091 tracing::trace!(changes = changes.len(), "diff computed");
1092}
1093
1094fn compute_dirty_changes(
1095 old: &Buffer,
1096 new: &Buffer,
1097 changes: &mut Vec<(u16, u16)>,
1098 tile_builder: &mut TileDiffBuilder,
1099 tile_config: &TileDiffConfig,
1100 tile_stats_out: &mut Option<TileDiffStats>,
1101) {
1102 assert_eq!(old.width(), new.width(), "buffer widths must match");
1103 assert_eq!(old.height(), new.height(), "buffer heights must match");
1104
1105 let width = old.width();
1106 let height = old.height();
1107 let w = width as usize;
1108
1109 changes.clear();
1110 reserve_changes_capacity(width, height, changes);
1111
1112 let old_cells = old.cells();
1113 let new_cells = new.cells();
1114 let dirty = new.dirty_rows();
1115
1116 *tile_stats_out = None;
1117 let tile_build = tile_builder.build_from_buffer(tile_config, new);
1118
1119 if let TileDiffBuild::UseTiles(plan) = tile_build {
1120 *tile_stats_out = Some(plan.stats);
1121 let tile_w = plan.params.tile_w as usize;
1122 let tile_h = plan.params.tile_h as usize;
1123 let tiles_x = plan.params.tiles_x;
1124 let dirty_tiles = plan.dirty_tiles;
1125
1126 for y in 0..height {
1127 if !dirty[y as usize] {
1128 continue;
1129 }
1130
1131 let row_start = y as usize * w;
1132 let old_row = &old_cells[row_start..row_start + w];
1133 let new_row = &new_cells[row_start..row_start + w];
1134
1135 if old_row == new_row {
1136 continue;
1137 }
1138
1139 let tile_y = y as usize / tile_h;
1140 let tile_row_base = tile_y * tiles_x;
1141 debug_assert!(tile_row_base + tiles_x <= dirty_tiles.len());
1142
1143 let span_row = new.dirty_span_row(y);
1144 if let Some(span_row) = span_row {
1145 if span_row.is_full() {
1146 scan_row_tiles(
1147 old_row,
1148 new_row,
1149 y,
1150 w,
1151 tile_w,
1152 tiles_x,
1153 tile_row_base,
1154 dirty_tiles,
1155 changes,
1156 );
1157 continue;
1158 }
1159 let spans = span_row.spans();
1160 if spans.is_empty() {
1161 scan_row_tiles(
1162 old_row,
1163 new_row,
1164 y,
1165 w,
1166 tile_w,
1167 tiles_x,
1168 tile_row_base,
1169 dirty_tiles,
1170 changes,
1171 );
1172 continue;
1173 }
1174 scan_row_tiles_spans(
1175 old_row,
1176 new_row,
1177 y,
1178 w,
1179 tile_w,
1180 tiles_x,
1181 tile_row_base,
1182 dirty_tiles,
1183 spans,
1184 changes,
1185 );
1186 } else {
1187 scan_row_tiles(
1188 old_row,
1189 new_row,
1190 y,
1191 w,
1192 tile_w,
1193 tiles_x,
1194 tile_row_base,
1195 dirty_tiles,
1196 changes,
1197 );
1198 }
1199 }
1200 return;
1201 }
1202
1203 if let TileDiffBuild::Fallback(stats) = tile_build {
1204 *tile_stats_out = Some(stats);
1205 }
1206
1207 for y in 0..height {
1208 if !dirty[y as usize] {
1210 continue;
1211 }
1212
1213 let row_start = y as usize * w;
1214 let old_row = &old_cells[row_start..row_start + w];
1215 let new_row = &new_cells[row_start..row_start + w];
1216
1217 if old_row == new_row {
1220 continue;
1221 }
1222
1223 let span_row = new.dirty_span_row(y);
1224 if let Some(span_row) = span_row {
1225 if span_row.is_full() {
1226 scan_row_changes(old_row, new_row, y, changes);
1227 continue;
1228 }
1229 let spans = span_row.spans();
1230 if spans.is_empty() {
1231 scan_row_changes(old_row, new_row, y, changes);
1232 continue;
1233 }
1234 scan_row_changes_spans(old_row, new_row, y, spans, changes);
1235 } else {
1236 scan_row_changes(old_row, new_row, y, changes);
1237 }
1238 }
1239}
1240
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1246pub struct ChangeRun {
1247 pub y: u16,
1249 pub x0: u16,
1251 pub x1: u16,
1253}
1254
1255impl ChangeRun {
1256 #[inline]
1258 pub const fn new(y: u16, x0: u16, x1: u16) -> Self {
1259 debug_assert!(x0 <= x1);
1260 Self { y, x0, x1 }
1261 }
1262
1263 #[inline]
1265 pub const fn len(&self) -> usize {
1266 self.x1.saturating_sub(self.x0) as usize + 1
1267 }
1268
1269 #[inline]
1271 pub const fn is_empty(&self) -> bool {
1272 self.x1 < self.x0
1273 }
1274}
1275
1276#[derive(Debug, Clone, Default)]
1280pub struct BufferDiff {
1281 changes: Vec<(u16, u16)>,
1283 tile_builder: TileDiffBuilder,
1285 tile_config: TileDiffConfig,
1287 last_tile_stats: Option<TileDiffStats>,
1289}
1290
1291impl BufferDiff {
1292 pub fn new() -> Self {
1294 Self {
1295 changes: Vec::new(),
1296 tile_builder: TileDiffBuilder::default(),
1297 tile_config: TileDiffConfig::default(),
1298 last_tile_stats: None,
1299 }
1300 }
1301
1302 pub fn with_capacity(capacity: usize) -> Self {
1304 let mut diff = Self::new();
1305 diff.changes = Vec::with_capacity(capacity);
1306 diff
1307 }
1308
1309 pub fn full(width: u16, height: u16) -> Self {
1314 if width == 0 || height == 0 {
1315 return Self::new();
1316 }
1317
1318 let total = width as usize * height as usize;
1319 let mut changes = Vec::with_capacity(total);
1320 for y in 0..height {
1321 for x in 0..width {
1322 changes.push((x, y));
1323 }
1324 }
1325 let mut diff = Self::new();
1326 diff.changes = changes;
1327 diff
1328 }
1329
1330 pub fn compute(old: &Buffer, new: &Buffer) -> Self {
1353 let mut diff = Self::new();
1354 diff.compute_into(old, new);
1355 diff
1356 }
1357
1358 pub fn compute_into(&mut self, old: &Buffer, new: &Buffer) {
1360 self.last_tile_stats = None;
1361 compute_changes(old, new, &mut self.changes);
1362 }
1363
1364 pub fn compute_dirty(old: &Buffer, new: &Buffer) -> Self {
1376 let mut diff = Self::new();
1377 diff.compute_dirty_into(old, new);
1378 diff
1379 }
1380
1381 pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
1383 compute_dirty_changes(
1384 old,
1385 new,
1386 &mut self.changes,
1387 &mut self.tile_builder,
1388 &self.tile_config,
1389 &mut self.last_tile_stats,
1390 );
1391 }
1392
1393 pub fn compute_certified_into(&mut self, old: &Buffer, new: &Buffer, hint: DiffSkipHint) {
1417 match hint {
1418 DiffSkipHint::FullDiff => {
1419 self.compute_dirty_into(old, new);
1421 }
1422 DiffSkipHint::SkipDiff => {
1423 self.changes.clear();
1425 self.last_tile_stats = None;
1426 #[cfg(feature = "tracing")]
1427 tracing::debug!(
1428 event = "diff_skip_certified",
1429 hint = "skip_diff",
1430 changes = 0,
1431 );
1432 }
1433 DiffSkipHint::NarrowToRows(ref rows) => {
1434 self.changes.clear();
1436 self.last_tile_stats = None;
1437
1438 let w = old.width();
1439 let h = old.height();
1440 debug_assert_eq!(w, new.width());
1441 debug_assert_eq!(h, new.height());
1442
1443 let old_cells = old.cells();
1444 let new_cells = new.cells();
1445 let stride = w as usize;
1446
1447 for &row in rows {
1448 if row >= h {
1449 continue;
1450 }
1451 let start = row as usize * stride;
1452 let end = start + stride;
1453 if end > old_cells.len() || end > new_cells.len() {
1454 continue;
1455 }
1456 let old_row = &old_cells[start..end];
1457 let new_row = &new_cells[start..end];
1458 if old_row == new_row {
1459 continue;
1460 }
1461 for (x, (o, n)) in old_row.iter().zip(new_row.iter()).enumerate() {
1462 if !o.bits_eq(n) {
1463 self.changes.push((x as u16, row));
1464 }
1465 }
1466 }
1467
1468 #[cfg(feature = "tracing")]
1469 tracing::debug!(
1470 event = "diff_narrow_certified",
1471 hint = "narrow_to_rows",
1472 rows_checked = rows.len(),
1473 changes = self.changes.len(),
1474 );
1475 }
1476 }
1477 }
1478
1479 pub fn fill_full(&mut self, width: u16, height: u16) {
1481 self.changes.clear();
1482 let total = width as usize * height as usize;
1483 if self.changes.capacity() < total {
1484 self.changes.reserve(total - self.changes.len());
1485 }
1486 for y in 0..height {
1487 for x in 0..width {
1488 self.changes.push((x, y));
1489 }
1490 }
1491 }
1492
1493 #[inline]
1495 pub fn len(&self) -> usize {
1496 self.changes.len()
1497 }
1498
1499 #[inline]
1501 pub fn is_empty(&self) -> bool {
1502 self.changes.is_empty()
1503 }
1504
1505 #[inline]
1507 pub fn changes(&self) -> &[(u16, u16)] {
1508 &self.changes
1509 }
1510
1511 #[inline]
1513 pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
1514 self.last_tile_stats
1515 }
1516
1517 #[inline]
1519 pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
1520 &mut self.tile_config
1521 }
1522
1523 pub fn runs(&self) -> Vec<ChangeRun> {
1528 #[cfg(feature = "tracing")]
1529 let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
1530 #[cfg(feature = "tracing")]
1531 let _guard = _span.enter();
1532
1533 if self.changes.is_empty() {
1534 return Vec::new();
1535 }
1536
1537 let sorted = &self.changes;
1540 let len = sorted.len();
1541
1542 let mut runs = Vec::with_capacity(len);
1545
1546 let mut i = 0;
1547
1548 while i < len {
1549 let (x0, y) = sorted[i];
1550 let mut x1 = x0;
1551 i += 1;
1552
1553 while i < len {
1555 let (x, yy) = sorted[i];
1556 if yy != y || x != x1.saturating_add(1) {
1557 break;
1558 }
1559 x1 = x;
1560 i += 1;
1561 }
1562
1563 runs.push(ChangeRun::new(y, x0, x1));
1564 }
1565
1566 #[cfg(feature = "tracing")]
1567 tracing::trace!(run_count = runs.len(), "runs coalesced");
1568
1569 runs
1570 }
1571
1572 pub fn runs_into(&self, out: &mut Vec<ChangeRun>) {
1575 out.clear();
1576
1577 #[cfg(feature = "tracing")]
1578 let _span = tracing::debug_span!("diff_runs_into", changes = self.changes.len());
1579 #[cfg(feature = "tracing")]
1580 let _guard = _span.enter();
1581
1582 if self.changes.is_empty() {
1583 return;
1584 }
1585
1586 let sorted = &self.changes;
1587 let len = sorted.len();
1588 let mut i = 0;
1589
1590 while i < len {
1591 let (x0, y) = sorted[i];
1592 let mut x1 = x0;
1593 i += 1;
1594
1595 while i < len {
1596 let (x, yy) = sorted[i];
1597 if yy != y || x != x1.saturating_add(1) {
1598 break;
1599 }
1600 x1 = x;
1601 i += 1;
1602 }
1603
1604 out.push(ChangeRun::new(y, x0, x1));
1605 }
1606
1607 #[cfg(feature = "tracing")]
1608 tracing::trace!(run_count = out.len(), "runs coalesced (reuse)");
1609 }
1610
1611 #[inline]
1613 pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
1614 self.changes.iter().copied()
1615 }
1616
1617 pub fn clear(&mut self) {
1619 self.changes.clear();
1620 }
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625 fn is_coverage_run() -> bool {
1626 if let Ok(value) = std::env::var("FTUI_COVERAGE") {
1627 let value = value.to_ascii_lowercase();
1628 if matches!(value.as_str(), "1" | "true" | "yes") {
1629 return true;
1630 }
1631 if matches!(value.as_str(), "0" | "false" | "no") {
1632 return false;
1633 }
1634 }
1635 std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
1636 }
1637 use super::*;
1638 use crate::cell::{Cell, PackedRgba};
1639
1640 #[test]
1641 fn scan_row_changes_range_reports_late_change_after_equal_quad() {
1642 let old = vec![Cell::default(); 8];
1643 let mut new = old.clone();
1644 let mut changes = Vec::new();
1645
1646 new[7] = Cell::from_char('X');
1647 scan_row_changes_range(&old, &new, 4, 10, &mut changes);
1648
1649 assert_eq!(changes, vec![(17, 4)]);
1650 }
1651
1652 #[test]
1653 fn empty_diff_when_buffers_identical() {
1654 let buf1 = Buffer::new(10, 10);
1655 let buf2 = Buffer::new(10, 10);
1656 let diff = BufferDiff::compute(&buf1, &buf2);
1657
1658 assert!(diff.is_empty());
1659 assert_eq!(diff.len(), 0);
1660 }
1661
1662 #[test]
1663 fn full_diff_marks_all_cells() {
1664 let diff = BufferDiff::full(3, 2);
1665 assert_eq!(diff.len(), 6);
1666 assert_eq!(diff.changes()[0], (0, 0));
1667 assert_eq!(diff.changes()[5], (2, 1));
1668 }
1669
1670 #[test]
1671 fn single_cell_change_detected() {
1672 let old = Buffer::new(10, 10);
1673 let mut new = Buffer::new(10, 10);
1674
1675 new.set_raw(5, 5, Cell::from_char('X'));
1676 let diff = BufferDiff::compute(&old, &new);
1677
1678 assert_eq!(diff.len(), 1);
1679 assert_eq!(diff.changes(), &[(5, 5)]);
1680 }
1681
1682 #[test]
1683 fn dirty_row_false_positive_skipped() {
1684 let old = Buffer::new(8, 2);
1685 let mut new = old.clone();
1686
1687 new.clear_dirty();
1689 new.set_raw(2, 0, Cell::default());
1691
1692 let diff = BufferDiff::compute_dirty(&old, &new);
1693 assert!(
1694 diff.is_empty(),
1695 "dirty row with no changes should be skipped"
1696 );
1697 }
1698
1699 #[test]
1700 fn multiple_scattered_changes_detected() {
1701 let old = Buffer::new(10, 10);
1702 let mut new = Buffer::new(10, 10);
1703
1704 new.set_raw(0, 0, Cell::from_char('A'));
1705 new.set_raw(9, 9, Cell::from_char('B'));
1706 new.set_raw(5, 3, Cell::from_char('C'));
1707
1708 let diff = BufferDiff::compute(&old, &new);
1709
1710 assert_eq!(diff.len(), 3);
1711 let changes = diff.changes();
1713 assert!(changes.contains(&(0, 0)));
1714 assert!(changes.contains(&(9, 9)));
1715 assert!(changes.contains(&(5, 3)));
1716 }
1717
1718 #[test]
1719 fn runs_coalesces_adjacent_cells() {
1720 let old = Buffer::new(10, 10);
1721 let mut new = Buffer::new(10, 10);
1722
1723 new.set_raw(3, 5, Cell::from_char('A'));
1725 new.set_raw(4, 5, Cell::from_char('B'));
1726 new.set_raw(5, 5, Cell::from_char('C'));
1727
1728 let diff = BufferDiff::compute(&old, &new);
1729 let runs = diff.runs();
1730
1731 assert_eq!(runs.len(), 1);
1732 assert_eq!(runs[0].y, 5);
1733 assert_eq!(runs[0].x0, 3);
1734 assert_eq!(runs[0].x1, 5);
1735 assert_eq!(runs[0].len(), 3);
1736 }
1737
1738 #[test]
1739 fn runs_handles_gaps_correctly() {
1740 let old = Buffer::new(10, 10);
1741 let mut new = Buffer::new(10, 10);
1742
1743 new.set_raw(0, 0, Cell::from_char('A'));
1745 new.set_raw(1, 0, Cell::from_char('B'));
1746 new.set_raw(3, 0, Cell::from_char('C'));
1748 new.set_raw(4, 0, Cell::from_char('D'));
1749
1750 let diff = BufferDiff::compute(&old, &new);
1751 let runs = diff.runs();
1752
1753 assert_eq!(runs.len(), 2);
1754
1755 assert_eq!(runs[0].y, 0);
1756 assert_eq!(runs[0].x0, 0);
1757 assert_eq!(runs[0].x1, 1);
1758
1759 assert_eq!(runs[1].y, 0);
1760 assert_eq!(runs[1].x0, 3);
1761 assert_eq!(runs[1].x1, 4);
1762 }
1763
1764 #[test]
1765 fn runs_handles_max_column_without_overflow() {
1766 let mut diff = BufferDiff::new();
1767 diff.changes = vec![(u16::MAX, 0)];
1768
1769 let runs = diff.runs();
1770
1771 assert_eq!(runs.len(), 1);
1772 assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1773 }
1774
1775 #[test]
1776 fn runs_handles_multiple_rows() {
1777 let old = Buffer::new(10, 10);
1778 let mut new = Buffer::new(10, 10);
1779
1780 new.set_raw(0, 0, Cell::from_char('A'));
1782 new.set_raw(1, 0, Cell::from_char('B'));
1783 new.set_raw(5, 2, Cell::from_char('C'));
1784 new.set_raw(0, 5, Cell::from_char('D'));
1785
1786 let diff = BufferDiff::compute(&old, &new);
1787 let runs = diff.runs();
1788
1789 assert_eq!(runs.len(), 3);
1790
1791 assert_eq!(runs[0].y, 0);
1793 assert_eq!(runs[0].x0, 0);
1794 assert_eq!(runs[0].x1, 1);
1795
1796 assert_eq!(runs[1].y, 2);
1798 assert_eq!(runs[1].x0, 5);
1799 assert_eq!(runs[1].x1, 5);
1800
1801 assert_eq!(runs[2].y, 5);
1803 assert_eq!(runs[2].x0, 0);
1804 assert_eq!(runs[2].x1, 0);
1805 }
1806
1807 #[test]
1808 fn empty_runs_from_empty_diff() {
1809 let diff = BufferDiff::new();
1810 let runs = diff.runs();
1811 assert!(runs.is_empty());
1812 }
1813
1814 #[test]
1815 fn change_run_len() {
1816 let run = ChangeRun::new(0, 5, 10);
1817 assert_eq!(run.len(), 6);
1818
1819 let single = ChangeRun::new(0, 5, 5);
1820 assert_eq!(single.len(), 1);
1821 }
1822
1823 #[test]
1824 fn color_changes_detected() {
1825 let old = Buffer::new(10, 10);
1826 let mut new = Buffer::new(10, 10);
1827
1828 new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1830
1831 let diff = BufferDiff::compute(&old, &new);
1832 assert_eq!(diff.len(), 1);
1833 }
1834
1835 #[test]
1836 fn diff_iter() {
1837 let old = Buffer::new(5, 5);
1838 let mut new = Buffer::new(5, 5);
1839 new.set_raw(1, 1, Cell::from_char('X'));
1840 new.set_raw(2, 2, Cell::from_char('Y'));
1841
1842 let diff = BufferDiff::compute(&old, &new);
1843 let positions: Vec<_> = diff.iter().collect();
1844
1845 assert_eq!(positions.len(), 2);
1846 assert!(positions.contains(&(1, 1)));
1847 assert!(positions.contains(&(2, 2)));
1848 }
1849
1850 #[test]
1851 fn diff_clear() {
1852 let old = Buffer::new(5, 5);
1853 let mut new = Buffer::new(5, 5);
1854 new.set_raw(1, 1, Cell::from_char('X'));
1855
1856 let mut diff = BufferDiff::compute(&old, &new);
1857 assert_eq!(diff.len(), 1);
1858
1859 diff.clear();
1860 assert!(diff.is_empty());
1861 }
1862
1863 #[test]
1864 fn with_capacity() {
1865 let diff = BufferDiff::with_capacity(100);
1866 assert!(diff.is_empty());
1867 }
1868
1869 #[test]
1870 fn full_buffer_change() {
1871 let old = Buffer::new(5, 5);
1872 let mut new = Buffer::new(5, 5);
1873
1874 for y in 0..5 {
1876 for x in 0..5 {
1877 new.set_raw(x, y, Cell::from_char('#'));
1878 }
1879 }
1880
1881 let diff = BufferDiff::compute(&old, &new);
1882 assert_eq!(diff.len(), 25);
1883
1884 let runs = diff.runs();
1886 assert_eq!(runs.len(), 5);
1887
1888 for (i, run) in runs.iter().enumerate() {
1889 assert_eq!(run.y, i as u16);
1890 assert_eq!(run.x0, 0);
1891 assert_eq!(run.x1, 4);
1892 assert_eq!(run.len(), 5);
1893 }
1894 }
1895
1896 #[test]
1897 fn row_major_order_preserved() {
1898 let old = Buffer::new(3, 3);
1899 let mut new = Buffer::new(3, 3);
1900
1901 new.set_raw(2, 2, Cell::from_char('C'));
1903 new.set_raw(0, 0, Cell::from_char('A'));
1904 new.set_raw(1, 1, Cell::from_char('B'));
1905
1906 let diff = BufferDiff::compute(&old, &new);
1907
1908 let changes = diff.changes();
1910 assert_eq!(changes[0], (0, 0));
1911 assert_eq!(changes[1], (1, 1));
1912 assert_eq!(changes[2], (2, 2));
1913 }
1914
1915 #[test]
1916 fn blockwise_scan_preserves_sparse_row_changes() {
1917 let old = Buffer::new(64, 2);
1918 let mut new = old.clone();
1919
1920 new.set_raw(1, 0, Cell::from_char('A'));
1921 new.set_raw(33, 0, Cell::from_char('B'));
1922 new.set_raw(62, 1, Cell::from_char('C'));
1923
1924 let diff = BufferDiff::compute(&old, &new);
1925 assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1926 }
1927
1928 #[test]
1929 fn rows_with_no_changes_are_skipped() {
1930 let old = Buffer::new(4, 3);
1931 let mut new = old.clone();
1932
1933 new.set_raw(1, 1, Cell::from_char('X'));
1934 new.set_raw(3, 1, Cell::from_char('Y'));
1935
1936 let diff = BufferDiff::compute(&old, &new);
1937 assert_eq!(diff.len(), 2);
1938 assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1939 }
1940
1941 #[test]
1942 fn clear_retains_capacity_for_reuse() {
1943 let mut diff = BufferDiff::with_capacity(16);
1944 diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1945 let capacity = diff.changes.capacity();
1946
1947 diff.clear();
1948
1949 assert!(diff.is_empty());
1950 assert!(diff.changes.capacity() >= capacity);
1951 }
1952
1953 #[test]
1954 #[should_panic(expected = "buffer widths must match")]
1955 fn compute_panics_on_width_mismatch() {
1956 let old = Buffer::new(5, 5);
1957 let new = Buffer::new(4, 5);
1958 let _ = BufferDiff::compute(&old, &new);
1959 }
1960
1961 #[test]
1966 fn block_scan_alignment_exact_block() {
1967 let old = Buffer::new(4, 1);
1969 let mut new = Buffer::new(4, 1);
1970 new.set_raw(2, 0, Cell::from_char('X'));
1971
1972 let diff = BufferDiff::compute(&old, &new);
1973 assert_eq!(diff.len(), 1);
1974 assert_eq!(diff.changes(), &[(2, 0)]);
1975 }
1976
1977 #[test]
1978 fn block_scan_alignment_remainder() {
1979 let old = Buffer::new(7, 1);
1981 let mut new = Buffer::new(7, 1);
1982 new.set_raw(1, 0, Cell::from_char('A'));
1984 new.set_raw(5, 0, Cell::from_char('B'));
1986 new.set_raw(6, 0, Cell::from_char('C'));
1987
1988 let diff = BufferDiff::compute(&old, &new);
1989 assert_eq!(diff.len(), 3);
1990 assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1991 }
1992
1993 #[test]
1994 fn block_scan_single_cell_row() {
1995 let old = Buffer::new(1, 1);
1997 let mut new = Buffer::new(1, 1);
1998 new.set_raw(0, 0, Cell::from_char('X'));
1999
2000 let diff = BufferDiff::compute(&old, &new);
2001 assert_eq!(diff.len(), 1);
2002 assert_eq!(diff.changes(), &[(0, 0)]);
2003 }
2004
2005 #[test]
2006 fn block_scan_two_cell_row() {
2007 let old = Buffer::new(2, 1);
2009 let mut new = Buffer::new(2, 1);
2010 new.set_raw(0, 0, Cell::from_char('A'));
2011 new.set_raw(1, 0, Cell::from_char('B'));
2012
2013 let diff = BufferDiff::compute(&old, &new);
2014 assert_eq!(diff.len(), 2);
2015 assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
2016 }
2017
2018 #[test]
2019 fn block_scan_three_cell_row() {
2020 let old = Buffer::new(3, 1);
2022 let mut new = Buffer::new(3, 1);
2023 new.set_raw(2, 0, Cell::from_char('X'));
2024
2025 let diff = BufferDiff::compute(&old, &new);
2026 assert_eq!(diff.len(), 1);
2027 assert_eq!(diff.changes(), &[(2, 0)]);
2028 }
2029
2030 #[test]
2031 fn block_scan_multiple_blocks_sparse() {
2032 let old = Buffer::new(80, 1);
2034 let mut new = Buffer::new(80, 1);
2035
2036 for block in (0..20).step_by(2) {
2038 let x = (block * 4 + 1) as u16;
2039 new.set_raw(x, 0, Cell::from_char('X'));
2040 }
2041
2042 let diff = BufferDiff::compute(&old, &new);
2043 assert_eq!(diff.len(), 10);
2044 assert_eq!(
2045 diff.changes(),
2046 &[
2047 (1, 0),
2048 (9, 0),
2049 (17, 0),
2050 (25, 0),
2051 (33, 0),
2052 (41, 0),
2053 (49, 0),
2054 (57, 0),
2055 (65, 0),
2056 (73, 0)
2057 ]
2058 );
2059 }
2060
2061 #[test]
2062 fn block_scan_full_block_unchanged_skip() {
2063 let old = Buffer::new(20, 1);
2065 let mut new = Buffer::new(20, 1);
2066
2067 new.set_raw(19, 0, Cell::from_char('Z'));
2069
2070 let diff = BufferDiff::compute(&old, &new);
2071 assert_eq!(diff.len(), 1);
2072 assert_eq!(diff.changes(), &[(19, 0)]);
2073 }
2074
2075 #[test]
2076 fn block_scan_wide_row_all_changed() {
2077 let old = Buffer::new(120, 1);
2079 let mut new = Buffer::new(120, 1);
2080 for x in 0..120 {
2081 new.set_raw(x, 0, Cell::from_char('#'));
2082 }
2083
2084 let diff = BufferDiff::compute(&old, &new);
2085 assert_eq!(diff.len(), 120);
2086 }
2087
2088 #[test]
2089 fn perf_block_scan_vs_scalar_baseline() {
2090 use std::time::Instant;
2093
2094 let width = 200u16;
2095 let height = 50u16;
2096 let old = Buffer::new(width, height);
2097 let mut new = Buffer::new(width, height);
2098
2099 for i in 0..1000 {
2101 let x = (i * 7 + 3) as u16 % width;
2102 let y = (i * 11 + 5) as u16 % height;
2103 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2104 new.set_raw(x, y, Cell::from_char(ch));
2105 }
2106
2107 let iterations = 1000u32;
2108 let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
2109 .ok()
2110 .and_then(|value| value.parse::<usize>().ok())
2111 .unwrap_or(50)
2112 .clamp(1, iterations as usize);
2113 let iters_per_sample = (iterations / samples as u32).max(1) as u64;
2114
2115 let mut times_us = Vec::with_capacity(samples);
2116 let mut last_checksum = 0u64;
2117
2118 for _ in 0..samples {
2119 let start = Instant::now();
2120 for _ in 0..iters_per_sample {
2121 let diff = BufferDiff::compute(&old, &new);
2122 assert!(!diff.is_empty());
2123 last_checksum = fnv1a_hash(diff.changes());
2124 }
2125 let elapsed = start.elapsed();
2126 let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
2127 times_us.push(per_iter);
2128 }
2129
2130 times_us.sort_unstable();
2131 let len = times_us.len();
2132 let p50 = times_us[len / 2];
2133 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2134 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
2135 let mean = times_us
2136 .iter()
2137 .copied()
2138 .map(|value| value as f64)
2139 .sum::<f64>()
2140 / len as f64;
2141 let variance = times_us
2142 .iter()
2143 .map(|value| {
2144 let delta = *value as f64 - mean;
2145 delta * delta
2146 })
2147 .sum::<f64>()
2148 / len as f64;
2149
2150 eprintln!(
2152 "{{\"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}\"}}",
2153 width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
2154 );
2155
2156 #[allow(unexpected_cfgs)]
2160 let p50_budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
2161 #[allow(unexpected_cfgs)]
2162 let p95_budget_us = if is_coverage_run() {
2163 10_000u64
2164 } else {
2165 2_500u64
2166 };
2167
2168 assert!(
2169 p50 <= p50_budget_us,
2170 "Diff too slow: p50={p50}µs (budget {p50_budget_us}µs) for {width}x{height}"
2171 );
2172 assert!(
2173 p95 <= p95_budget_us,
2174 "Diff tail too slow: p95={p95}µs (budget {p95_budget_us}µs) for {width}x{height}"
2175 );
2176 }
2177 #[test]
2182 fn unit_run_coalescing_invariants() {
2183 let old = Buffer::new(80, 24);
2186 let mut new = Buffer::new(80, 24);
2187
2188 for x in 0..=2 {
2190 new.set_raw(x, 0, Cell::from_char('A'));
2191 }
2192 for x in 10..=12 {
2193 new.set_raw(x, 0, Cell::from_char('B'));
2194 }
2195 for x in 40..=45 {
2197 new.set_raw(x, 5, Cell::from_char('C'));
2198 }
2199 new.set_raw(79, 23, Cell::from_char('Z'));
2201
2202 let diff = BufferDiff::compute(&old, &new);
2203 let runs = diff.runs();
2204
2205 for w in runs.windows(2) {
2207 assert!(
2208 (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
2209 "runs must be sorted: {:?} should precede {:?}",
2210 w[0],
2211 w[1]
2212 );
2213 }
2214
2215 let total_cells: usize = runs.iter().map(|r| r.len()).sum();
2217 assert_eq!(
2218 total_cells,
2219 diff.len(),
2220 "runs must cover all changes exactly"
2221 );
2222
2223 for w in runs.windows(2) {
2225 if w[0].y == w[1].y {
2226 assert!(
2227 w[1].x0 > w[0].x1.saturating_add(1),
2228 "adjacent runs on same row should be merged: {:?} and {:?}",
2229 w[0],
2230 w[1]
2231 );
2232 }
2233 }
2234
2235 assert_eq!(runs.len(), 4);
2237 assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
2238 assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
2239 assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
2240 assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
2241 }
2242
2243 fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
2249 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2250 for &(x, y) in data {
2251 for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
2252 hash ^= *byte as u64;
2253 hash = hash.wrapping_mul(0x0100_0000_01b3);
2254 }
2255 }
2256 hash
2257 }
2258
2259 fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
2262 let mut buf = Buffer::new(width, height);
2263 let mut rng = seed;
2264
2265 for x in 0..width {
2267 buf.set_raw(x, 0, Cell::from_char('='));
2268 }
2269
2270 for x in 0..width {
2272 buf.set_raw(x, height - 1, Cell::from_char('-'));
2273 }
2274
2275 let count = (width as u64 * height as u64 / 10).max(5);
2277 for _ in 0..count {
2278 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2279 let x = ((rng >> 16) as u16) % width;
2280 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2281 let y = ((rng >> 16) as u16) % height;
2282 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2283 let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
2284 buf.set_raw(x, y, Cell::from_char(ch));
2285 }
2286
2287 buf
2288 }
2289
2290 #[test]
2291 fn golden_diff_80x24() {
2292 let old = Buffer::new(80, 24);
2293 let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
2294
2295 let diff = BufferDiff::compute(&old, &new);
2296 let checksum = fnv1a_hash(diff.changes());
2297
2298 let diff2 = BufferDiff::compute(&old, &new);
2300 assert_eq!(
2301 fnv1a_hash(diff2.changes()),
2302 checksum,
2303 "diff must be deterministic"
2304 );
2305
2306 assert!(
2308 diff.len() >= 160,
2309 "80x24 golden scene should have at least 160 changes (header+status), got {}",
2310 diff.len()
2311 );
2312
2313 let runs = diff.runs();
2314 assert_eq!(runs[0].y, 0, "first run should be header row");
2316 assert_eq!(runs[0].x0, 0);
2317 assert_eq!(runs[0].x1, 79);
2318 assert!(
2319 runs.last().unwrap().y == 23,
2320 "last row should contain status bar"
2321 );
2322 }
2323
2324 #[test]
2325 fn golden_diff_120x40() {
2326 let old = Buffer::new(120, 40);
2327 let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
2328
2329 let diff = BufferDiff::compute(&old, &new);
2330 let checksum = fnv1a_hash(diff.changes());
2331
2332 let diff2 = BufferDiff::compute(&old, &new);
2334 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
2335
2336 assert!(
2338 diff.len() >= 240,
2339 "120x40 golden scene should have >=240 changes, got {}",
2340 diff.len()
2341 );
2342
2343 let dirty = BufferDiff::compute_dirty(&old, &new);
2345 assert_eq!(
2346 fnv1a_hash(dirty.changes()),
2347 checksum,
2348 "dirty diff must produce identical changes"
2349 );
2350 }
2351
2352 #[test]
2353 fn golden_sparse_update() {
2354 let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
2356 let mut new = old.clone();
2357
2358 new.set_raw(10, 5, Cell::from_char('!'));
2360 new.set_raw(11, 5, Cell::from_char('@'));
2361 new.set_raw(40, 12, Cell::from_char('#'));
2362 new.set_raw(70, 20, Cell::from_char('$'));
2363 new.set_raw(0, 23, Cell::from_char('%'));
2364
2365 let diff = BufferDiff::compute(&old, &new);
2366 let checksum = fnv1a_hash(diff.changes());
2367
2368 let diff2 = BufferDiff::compute(&old, &new);
2370 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
2371
2372 assert!(
2374 diff.len() <= 5,
2375 "sparse update should have <=5 changes, got {}",
2376 diff.len()
2377 );
2378 assert!(
2379 diff.len() >= 3,
2380 "sparse update should have >=3 changes, got {}",
2381 diff.len()
2382 );
2383 }
2384
2385 #[test]
2390 fn e2e_random_scene_replay() {
2391 let width = 80u16;
2394 let height = 24u16;
2395 let base_seed: u64 = 0x5C3E_E3E1_A442u64;
2396
2397 let mut checksums = Vec::new();
2398
2399 for frame in 0..10u64 {
2400 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2401 let old = build_golden_scene(width, height, seed);
2402 let new = build_golden_scene(width, height, seed.wrapping_add(1));
2403
2404 let diff = BufferDiff::compute(&old, &new);
2405 let dirty_diff = BufferDiff::compute_dirty(&old, &new);
2406
2407 assert_eq!(
2409 diff.changes(),
2410 dirty_diff.changes(),
2411 "frame {frame}: dirty diff must match full diff"
2412 );
2413
2414 checksums.push(fnv1a_hash(diff.changes()));
2415 }
2416
2417 for frame in 0..10u64 {
2419 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2420 let old = build_golden_scene(width, height, seed);
2421 let new = build_golden_scene(width, height, seed.wrapping_add(1));
2422
2423 let diff = BufferDiff::compute(&old, &new);
2424 assert_eq!(
2425 fnv1a_hash(diff.changes()),
2426 checksums[frame as usize],
2427 "frame {frame}: checksum mismatch on replay"
2428 );
2429 }
2430 }
2431
2432 #[test]
2437 fn perf_diff_microbench() {
2438 use std::time::Instant;
2439
2440 let scenarios: &[(u16, u16, &str, u64)] = &[
2441 (80, 24, "full_frame", 0xBE4C_0001u64),
2442 (80, 24, "sparse_update", 0xBE4C_0002u64),
2443 (120, 40, "full_frame", 0xBE4C_0003u64),
2444 (120, 40, "sparse_update", 0xBE4C_0004u64),
2445 ];
2446
2447 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2448 .ok()
2449 .and_then(|value| value.parse::<u32>().ok())
2450 .unwrap_or(50u32);
2451
2452 for &(width, height, scene_type, seed) in scenarios {
2453 let old = Buffer::new(width, height);
2454 let new = match scene_type {
2455 "full_frame" => build_golden_scene(width, height, seed),
2456 "sparse_update" => {
2457 let mut buf = old.clone();
2458 buf.set_raw(10, 5, Cell::from_char('!'));
2459 buf.set_raw(40, 12, Cell::from_char('#'));
2460 buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
2461 buf
2462 }
2463 _ => unreachable!(),
2464 };
2465
2466 let mut times_us = Vec::with_capacity(iterations as usize);
2467 let mut last_changes = 0usize;
2468 let mut last_runs = 0usize;
2469 let mut last_checksum = 0u64;
2470
2471 for _ in 0..iterations {
2472 let start = Instant::now();
2473 let diff = BufferDiff::compute(&old, &new);
2474 let runs = diff.runs();
2475 let elapsed = start.elapsed();
2476
2477 last_changes = diff.len();
2478 last_runs = runs.len();
2479 last_checksum = fnv1a_hash(diff.changes());
2480 times_us.push(elapsed.as_micros() as u64);
2481 }
2482
2483 times_us.sort();
2484 let len = times_us.len();
2485 let p50 = times_us[len / 2];
2486 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2487 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
2488
2489 eprintln!(
2491 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
2492 seed,
2493 width,
2494 height,
2495 scene_type,
2496 last_changes,
2497 last_runs,
2498 p50,
2499 p95,
2500 p99,
2501 last_checksum
2502 );
2503
2504 let budget_us = match (width, height) {
2507 (80, 24) => 500, (120, 40) => 1000, _ => 2000,
2510 };
2511
2512 for _ in 0..3 {
2514 let diff = BufferDiff::compute(&old, &new);
2515 assert_eq!(
2516 fnv1a_hash(diff.changes()),
2517 last_checksum,
2518 "diff must be deterministic"
2519 );
2520 }
2521
2522 if p95 > budget_us {
2524 eprintln!(
2525 "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
2526 );
2527 }
2528 }
2529 }
2530
2531 #[test]
2536 fn perf_dirty_diff_large_screen_regression() {
2537 use std::time::Instant;
2538
2539 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2540 .ok()
2541 .and_then(|value| value.parse::<u32>().ok())
2542 .unwrap_or(50u32);
2543
2544 let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
2545 .ok()
2546 .and_then(|value| value.parse::<f64>().ok())
2547 .unwrap_or(2.0);
2548
2549 let cases: &[(u16, u16, &str, f64)] = &[
2550 (200, 60, "sparse_5pct", 5.0),
2551 (240, 80, "sparse_5pct", 5.0),
2552 (200, 60, "single_row", 0.0),
2553 (240, 80, "single_row", 0.0),
2554 ];
2555
2556 for &(width, height, pattern, pct) in cases {
2557 let old = Buffer::new(width, height);
2558 let mut new = old.clone();
2559
2560 if pattern == "single_row" {
2561 for x in 0..width {
2562 new.set_raw(x, 0, Cell::from_char('X'));
2563 }
2564 } else {
2565 let total = width as usize * height as usize;
2566 let to_change = ((total as f64) * pct / 100.0) as usize;
2567 for i in 0..to_change {
2568 let x = (i * 7 + 3) as u16 % width;
2569 let y = (i * 11 + 5) as u16 % height;
2570 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2571 new.set_raw(x, y, Cell::from_char(ch));
2572 }
2573 }
2574
2575 let full = BufferDiff::compute(&old, &new);
2577 let dirty = BufferDiff::compute_dirty(&old, &new);
2578 let change_count = full.len();
2579 let dirty_rows = new.dirty_row_count();
2580 assert_eq!(
2581 full.changes(),
2582 dirty.changes(),
2583 "dirty diff must match full diff for {width}x{height} {pattern}"
2584 );
2585
2586 let mut full_times = Vec::with_capacity(iterations as usize);
2587 let mut dirty_times = Vec::with_capacity(iterations as usize);
2588
2589 for _ in 0..iterations {
2590 let start = Instant::now();
2591 let diff = BufferDiff::compute(&old, &new);
2592 std::hint::black_box(diff.len());
2593 full_times.push(start.elapsed().as_micros() as u64);
2594
2595 let start = Instant::now();
2596 let diff = BufferDiff::compute_dirty(&old, &new);
2597 std::hint::black_box(diff.len());
2598 dirty_times.push(start.elapsed().as_micros() as u64);
2599 }
2600
2601 full_times.sort();
2602 dirty_times.sort();
2603
2604 let len = full_times.len();
2605 let p50_idx = len / 2;
2606 let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2607
2608 let full_p50 = full_times[p50_idx];
2609 let full_p95 = full_times[p95_idx];
2610 let dirty_p50 = dirty_times[p50_idx];
2611 let dirty_p95 = dirty_times[p95_idx];
2612
2613 let denom = full_p50.max(1) as f64;
2614 let ratio = dirty_p50 as f64 / denom;
2615
2616 eprintln!(
2617 "{{\"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\":{}}}",
2618 width,
2619 height,
2620 pattern,
2621 iterations,
2622 change_count,
2623 dirty_rows,
2624 full_p50,
2625 full_p95,
2626 dirty_p50,
2627 dirty_p95,
2628 ratio,
2629 max_slowdown
2630 );
2631
2632 assert!(
2633 ratio <= max_slowdown,
2634 "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2635 );
2636 }
2637 }
2638
2639 #[test]
2640 fn tile_diff_matches_compute_for_sparse_tiles() {
2641 let width = 200;
2642 let height = 60;
2643 let old = Buffer::new(width, height);
2644 let mut new = old.clone();
2645
2646 new.clear_dirty();
2647 for x in 0..10u16 {
2648 new.set_raw(x, 0, Cell::from_char('X'));
2649 }
2650
2651 let full = BufferDiff::compute(&old, &new);
2652 let dirty = BufferDiff::compute_dirty(&old, &new);
2653
2654 assert_eq!(full.changes(), dirty.changes());
2655 let stats = dirty
2656 .last_tile_stats()
2657 .expect("tile stats should be recorded");
2658 assert!(
2659 stats.fallback.is_none(),
2660 "tile path should be used for sparse tiles"
2661 );
2662 }
2663
2664 fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2665 let mut buffer = Buffer::new(width, height);
2666 buffer.clear_dirty();
2667 for &(x, y, ch) in changes {
2668 buffer.set_raw(x, y, Cell::from_char(ch));
2669 }
2670 buffer
2671 }
2672
2673 fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2674 let mut diff = BufferDiff::new();
2675 *diff.tile_config_mut() = config;
2676 diff.compute_dirty_into(old, new);
2677 diff.last_tile_stats()
2678 .expect("tile stats should be recorded")
2679 }
2680
2681 #[test]
2682 fn tile_fallback_disabled_when_config_off() {
2683 let width = 64;
2684 let height = 32;
2685 let old = Buffer::new(width, height);
2686 let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2687
2688 let config = TileDiffConfig {
2689 enabled: false,
2690 min_cells_for_tiles: 0,
2691 dense_cell_ratio: 1.1,
2692 dense_tile_ratio: 1.1,
2693 max_tiles: usize::MAX,
2694 ..Default::default()
2695 };
2696
2697 let stats = tile_stats_for_config(&old, &new, config);
2698 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2699 }
2700
2701 #[test]
2702 fn tile_fallback_small_screen_when_below_threshold() {
2703 let width = 64;
2704 let height = 32;
2705 let old = Buffer::new(width, height);
2706 let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2707
2708 let config = TileDiffConfig {
2709 enabled: true,
2710 min_cells_for_tiles: width as usize * height as usize + 1,
2711 dense_cell_ratio: 1.1,
2712 dense_tile_ratio: 1.1,
2713 max_tiles: usize::MAX,
2714 ..Default::default()
2715 };
2716
2717 let stats = tile_stats_for_config(&old, &new, config);
2718 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2719 }
2720
2721 #[test]
2722 fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2723 let width = 64;
2724 let height = 64;
2725 let old = Buffer::new(width, height);
2726 let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2727
2728 let config = TileDiffConfig {
2729 enabled: true,
2730 tile_w: 8,
2731 tile_h: 8,
2732 skip_clean_rows: true,
2733 min_cells_for_tiles: 0,
2734 dense_cell_ratio: 1.1,
2735 dense_tile_ratio: 1.1,
2736 max_tiles: 4,
2737 };
2738
2739 let stats = tile_stats_for_config(&old, &new, config);
2740 assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2741 }
2742
2743 #[test]
2744 fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2745 let width = 64;
2746 let height = 32;
2747 let old = Buffer::new(width, height);
2748 let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2749
2750 let config = TileDiffConfig {
2751 enabled: true,
2752 tile_w: 8,
2753 tile_h: 8,
2754 skip_clean_rows: true,
2755 min_cells_for_tiles: 0,
2756 dense_cell_ratio: 1.1,
2757 dense_tile_ratio: 0.05,
2758 max_tiles: usize::MAX / 4,
2759 };
2760
2761 let stats = tile_stats_for_config(&old, &new, config);
2762 assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2763 }
2764
2765 #[test]
2766 fn tile_builder_skips_clean_rows_when_enabled() {
2767 let width = 200;
2768 let height = 60;
2769 let old = Buffer::new(width, height);
2770 let mut new = old.clone();
2771 new.clear_dirty();
2772 new.set_raw(3, 0, Cell::from_char('X'));
2773 new.set_raw(4, 10, Cell::from_char('Y'));
2774
2775 let base = TileDiffConfig {
2776 enabled: true,
2777 tile_w: 16,
2778 tile_h: 8,
2779 min_cells_for_tiles: 0,
2780 dense_cell_ratio: 1.1,
2781 dense_tile_ratio: 1.1,
2782 max_tiles: usize::MAX,
2783 ..Default::default()
2784 };
2785 let config_full = TileDiffConfig {
2786 skip_clean_rows: false,
2787 ..base.clone()
2788 };
2789 let config_skip = TileDiffConfig {
2790 skip_clean_rows: true,
2791 ..base
2792 };
2793
2794 let stats_full = tile_stats_for_config(&old, &new, config_full);
2795 let stats_skip = tile_stats_for_config(&old, &new, config_skip);
2796
2797 assert!(
2799 stats_full.sat_build_cells > 0,
2800 "full scan should process cells, got 0"
2801 );
2802 assert!(
2804 stats_skip.sat_build_cells < stats_full.sat_build_cells,
2805 "skip_clean_rows should process fewer cells than full scan: skip={} full={}",
2806 stats_skip.sat_build_cells,
2807 stats_full.sat_build_cells
2808 );
2809 assert!(
2812 stats_skip.sat_build_cells <= stats_full.sat_build_cells / 2,
2813 "skip_clean_rows should save at least 50%: skip={} full={}",
2814 stats_skip.sat_build_cells,
2815 stats_full.sat_build_cells
2816 );
2817 }
2818
2819 fn lcg_next(state: &mut u64) -> u64 {
2820 *state = state
2821 .wrapping_mul(6364136223846793005)
2822 .wrapping_add(1442695040888963407);
2823 *state
2824 }
2825
2826 fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2827 let width = buf.width().max(1) as u64;
2828 let height = buf.height().max(1) as u64;
2829 let mut state = seed;
2830 for i in 0..count {
2831 let v = lcg_next(&mut state);
2832 let x = (v % width) as u16;
2833 let y = ((v >> 32) % height) as u16;
2834 let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2835 buf.set_raw(x, y, Cell::from_char(ch));
2836 }
2837 }
2838
2839 fn tile_diag(stats: &TileDiffStats) -> String {
2840 let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2841 format!(
2842 "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2843 stats.dirty_tiles,
2844 stats.skipped_tiles,
2845 stats.dirty_cells,
2846 stats.dirty_tile_ratio,
2847 stats.dirty_cell_ratio,
2848 stats.scanned_tiles,
2849 stats.fallback
2850 )
2851 }
2852
2853 fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2854 let mut diff = BufferDiff::new();
2855 {
2856 let config = diff.tile_config_mut();
2857 config.enabled = true;
2858 config.tile_w = 8;
2859 config.tile_h = 8;
2860 config.min_cells_for_tiles = 0;
2861 config.dense_cell_ratio = 1.1;
2862 config.dense_tile_ratio = 1.1;
2863 config.max_tiles = usize::MAX / 4;
2864 }
2865 diff.compute_dirty_into(old, new);
2866 let stats = diff
2867 .last_tile_stats()
2868 .expect("tile stats should be recorded");
2869 (diff, stats)
2870 }
2871
2872 fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2873 let full = BufferDiff::compute(old, new);
2874 let (dirty, stats) = diff_with_forced_tiles(old, new);
2875 let diag = tile_diag(&stats);
2876 assert!(
2877 stats.fallback.is_none(),
2878 "tile diff fallback ({label}) {w}x{h}: {diag}",
2879 w = old.width(),
2880 h = old.height()
2881 );
2882 assert!(
2883 full.changes() == dirty.changes(),
2884 "tile diff mismatch ({label}) {w}x{h}: {diag}",
2885 w = old.width(),
2886 h = old.height()
2887 );
2888 }
2889
2890 #[test]
2891 fn tile_diff_equivalence_small_and_odd_sizes() {
2892 let cases: &[(u16, u16, usize)] = &[
2893 (1, 1, 1),
2894 (2, 1, 1),
2895 (1, 2, 1),
2896 (5, 3, 4),
2897 (7, 13, 12),
2898 (15, 9, 20),
2899 (31, 5, 12),
2900 ];
2901
2902 for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2903 let old = Buffer::new(width, height);
2904 let mut new = old.clone();
2905 new.clear_dirty();
2906 apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2907 assert_tile_diff_equivalence(&old, &new, "small_odd");
2908 }
2909 }
2910
2911 #[test]
2912 fn tile_diff_equivalence_large_sparse_random() {
2913 let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2914 for (idx, &(width, height)) in cases.iter().enumerate() {
2915 let old = Buffer::new(width, height);
2916 let mut new = old.clone();
2917 new.clear_dirty();
2918 let total = width as usize * height as usize;
2919 let changes = (total / 100).max(1);
2920 apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2921 assert_tile_diff_equivalence(&old, &new, "large_sparse");
2922 }
2923 }
2924
2925 #[test]
2926 fn tile_diff_equivalence_row_and_full_buffer() {
2927 let width = 200u16;
2928 let height = 60u16;
2929 let old = Buffer::new(width, height);
2930
2931 let mut row = old.clone();
2932 row.clear_dirty();
2933 for x in 0..width {
2934 row.set_raw(x, 0, Cell::from_char('R'));
2935 }
2936 assert_tile_diff_equivalence(&old, &row, "single_row");
2937
2938 let mut full = old.clone();
2939 full.clear_dirty();
2940 for y in 0..height {
2941 for x in 0..width {
2942 full.set_raw(x, y, Cell::from_char('F'));
2943 }
2944 }
2945 assert_tile_diff_equivalence(&old, &full, "full_buffer");
2946 }
2947
2948 #[test]
2955 fn full_zero_width_returns_empty() {
2956 let diff = BufferDiff::full(0, 10);
2957 assert!(diff.is_empty());
2958 }
2959
2960 #[test]
2961 fn full_zero_height_returns_empty() {
2962 let diff = BufferDiff::full(10, 0);
2963 assert!(diff.is_empty());
2964 }
2965
2966 #[test]
2967 fn full_zero_both_returns_empty() {
2968 let diff = BufferDiff::full(0, 0);
2969 assert!(diff.is_empty());
2970 }
2971
2972 #[test]
2973 fn full_single_cell_has_one_change() {
2974 let diff = BufferDiff::full(1, 1);
2975 assert_eq!(diff.len(), 1);
2976 assert_eq!(diff.changes(), &[(0, 0)]);
2977 }
2978
2979 #[test]
2980 fn full_row_major_order() {
2981 let diff = BufferDiff::full(2, 3);
2982 assert_eq!(
2983 diff.changes(),
2984 &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]
2985 );
2986 }
2987
2988 #[test]
2991 fn runs_into_matches_runs_output() {
2992 let old = Buffer::new(20, 5);
2993 let mut new = Buffer::new(20, 5);
2994 new.set_raw(0, 0, Cell::from_char('A'));
2995 new.set_raw(1, 0, Cell::from_char('B'));
2996 new.set_raw(5, 2, Cell::from_char('C'));
2997 new.set_raw(10, 4, Cell::from_char('D'));
2998 new.set_raw(11, 4, Cell::from_char('E'));
2999
3000 let diff = BufferDiff::compute(&old, &new);
3001 let runs = diff.runs();
3002 let mut runs_buf = Vec::new();
3003 diff.runs_into(&mut runs_buf);
3004
3005 assert_eq!(runs, runs_buf);
3006 }
3007
3008 #[test]
3009 fn runs_into_clears_previous_content() {
3010 let diff = BufferDiff::new();
3011 let mut out = vec![ChangeRun::new(0, 0, 0)];
3012 diff.runs_into(&mut out);
3013 assert!(out.is_empty());
3014 }
3015
3016 #[test]
3017 fn runs_into_preserves_capacity() {
3018 let old = Buffer::new(10, 2);
3019 let mut new = Buffer::new(10, 2);
3020 new.set_raw(0, 0, Cell::from_char('A'));
3021 let diff = BufferDiff::compute(&old, &new);
3022
3023 let mut out = Vec::with_capacity(64);
3024 diff.runs_into(&mut out);
3025 assert_eq!(out.len(), 1);
3026 assert!(out.capacity() >= 64);
3027 }
3028
3029 #[test]
3032 fn change_run_fields_accessible() {
3033 let run = ChangeRun::new(7, 3, 10);
3034 assert_eq!(run.y, 7);
3035 assert_eq!(run.x0, 3);
3036 assert_eq!(run.x1, 10);
3037 assert_eq!(run.len(), 8);
3038 assert!(!run.is_empty());
3039 }
3040
3041 #[test]
3042 fn change_run_single_cell_not_empty() {
3043 let run = ChangeRun::new(0, 5, 5);
3044 assert_eq!(run.len(), 1);
3045 assert!(!run.is_empty());
3046 }
3047
3048 #[test]
3049 fn change_run_debug_format() {
3050 let run = ChangeRun::new(1, 2, 3);
3051 let dbg = format!("{:?}", run);
3052 assert!(dbg.contains("ChangeRun"), "Debug output: {dbg}");
3053 }
3054
3055 #[test]
3056 fn change_run_clone_copy_eq() {
3057 let run = ChangeRun::new(5, 10, 20);
3058 let copied = run; assert_eq!(run, copied);
3060 let cloned: ChangeRun = run; assert_eq!(run, cloned);
3062 }
3063
3064 #[test]
3065 fn change_run_ne() {
3066 let a = ChangeRun::new(0, 0, 5);
3067 let b = ChangeRun::new(0, 0, 6);
3068 assert_ne!(a, b);
3069 }
3070
3071 #[test]
3074 fn tile_diff_config_default_values() {
3075 let config = TileDiffConfig::default();
3076 assert!(config.enabled);
3077 assert_eq!(config.tile_w, 16);
3078 assert_eq!(config.tile_h, 8);
3079 assert!(config.skip_clean_rows);
3080 assert_eq!(config.min_cells_for_tiles, 12_000);
3081 assert!((config.dense_cell_ratio - 0.25).abs() < f64::EPSILON);
3082 assert!((config.dense_tile_ratio - 0.60).abs() < f64::EPSILON);
3083 assert_eq!(config.max_tiles, 4096);
3084 }
3085
3086 #[test]
3087 fn tile_diff_config_builder_methods() {
3088 let config = TileDiffConfig::default()
3089 .with_enabled(false)
3090 .with_tile_size(32, 32)
3091 .with_min_cells_for_tiles(500)
3092 .with_skip_clean_rows(false)
3093 .with_dense_cell_ratio(0.5)
3094 .with_dense_tile_ratio(0.8)
3095 .with_max_tiles(100);
3096
3097 assert!(!config.enabled);
3098 assert_eq!(config.tile_w, 32);
3099 assert_eq!(config.tile_h, 32);
3100 assert!(!config.skip_clean_rows);
3101 assert_eq!(config.min_cells_for_tiles, 500);
3102 assert!((config.dense_cell_ratio - 0.5).abs() < f64::EPSILON);
3103 assert!((config.dense_tile_ratio - 0.8).abs() < f64::EPSILON);
3104 assert_eq!(config.max_tiles, 100);
3105 }
3106
3107 #[test]
3108 fn tile_diff_config_debug_clone() {
3109 let config = TileDiffConfig::default();
3110 let dbg = format!("{:?}", config);
3111 assert!(dbg.contains("TileDiffConfig"), "Debug: {dbg}");
3112 let cloned = config.clone();
3113 assert_eq!(cloned.tile_w, 16);
3114 }
3115
3116 #[test]
3119 fn tile_diff_fallback_as_str_all_variants() {
3120 assert_eq!(TileDiffFallback::Disabled.as_str(), "disabled");
3121 assert_eq!(TileDiffFallback::SmallScreen.as_str(), "small_screen");
3122 assert_eq!(TileDiffFallback::DirtyAll.as_str(), "dirty_all");
3123 assert_eq!(TileDiffFallback::DenseCells.as_str(), "dense_cells");
3124 assert_eq!(TileDiffFallback::DenseTiles.as_str(), "dense_tiles");
3125 assert_eq!(TileDiffFallback::TooManyTiles.as_str(), "too_many_tiles");
3126 assert_eq!(TileDiffFallback::Overflow.as_str(), "overflow");
3127 }
3128
3129 #[test]
3130 fn tile_diff_fallback_traits() {
3131 let a = TileDiffFallback::Disabled;
3132 let b = a; assert_eq!(a, b);
3134 let c: TileDiffFallback = a; assert_eq!(a, c);
3136 assert_ne!(a, TileDiffFallback::Overflow);
3137 let dbg = format!("{:?}", a);
3138 assert!(dbg.contains("Disabled"), "Debug: {dbg}");
3139 }
3140
3141 #[test]
3144 fn tile_builder_dirty_all_fallback() {
3145 let mut builder = TileDiffBuilder::new();
3146 let config = TileDiffConfig::default().with_min_cells_for_tiles(0);
3147 let dirty_rows = vec![true; 10];
3148 let dirty_bits = vec![1u8; 200]; let input = TileDiffInput {
3151 width: 20,
3152 height: 10,
3153 dirty_rows: &dirty_rows,
3154 dirty_bits: &dirty_bits,
3155 dirty_cells: 200,
3156 dirty_all: true,
3157 };
3158
3159 let result = builder.build(&config, input);
3160 assert!(matches!(
3161 result,
3162 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DirtyAll)
3163 ));
3164 }
3165
3166 #[test]
3167 fn tile_builder_dense_cells_fallback() {
3168 let mut builder = TileDiffBuilder::new();
3170 let config = TileDiffConfig::default()
3171 .with_min_cells_for_tiles(0)
3172 .with_dense_cell_ratio(0.10); let w = 20u16;
3175 let h = 10u16;
3176 let total = (w as usize) * (h as usize);
3177 let dirty_count = total / 10; let dirty_rows = vec![true; h as usize];
3180 let dirty_bits = vec![0u8; total];
3181
3182 let input = TileDiffInput {
3183 width: w,
3184 height: h,
3185 dirty_rows: &dirty_rows,
3186 dirty_bits: &dirty_bits,
3187 dirty_cells: dirty_count,
3188 dirty_all: false,
3189 };
3190
3191 let result = builder.build(&config, input);
3192 assert!(matches!(
3193 result,
3194 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
3195 ));
3196 }
3197
3198 #[test]
3199 fn tile_builder_reuse_across_builds() {
3200 let mut builder = TileDiffBuilder::new();
3201 let config = TileDiffConfig::default()
3202 .with_min_cells_for_tiles(0)
3203 .with_dense_tile_ratio(1.1)
3204 .with_dense_cell_ratio(1.1)
3205 .with_max_tiles(usize::MAX / 4);
3206
3207 let dirty_rows_1 = vec![true; 10];
3209 let mut dirty_bits_1 = vec![0u8; 200];
3210 dirty_bits_1[0] = 1;
3211 let input_1 = TileDiffInput {
3212 width: 20,
3213 height: 10,
3214 dirty_rows: &dirty_rows_1,
3215 dirty_bits: &dirty_bits_1,
3216 dirty_cells: 1,
3217 dirty_all: false,
3218 };
3219 let result_1 = builder.build(&config, input_1);
3220 assert!(matches!(result_1, TileDiffBuild::UseTiles(_)));
3221
3222 let dirty_rows_2 = vec![true; 15];
3224 let mut dirty_bits_2 = vec![0u8; 450];
3225 dirty_bits_2[200] = 1;
3226 let input_2 = TileDiffInput {
3227 width: 30,
3228 height: 15,
3229 dirty_rows: &dirty_rows_2,
3230 dirty_bits: &dirty_bits_2,
3231 dirty_cells: 1,
3232 dirty_all: false,
3233 };
3234 let result_2 = builder.build(&config, input_2);
3235 assert!(matches!(result_2, TileDiffBuild::UseTiles(_)));
3236 }
3237
3238 #[test]
3239 fn tile_builder_default_matches_new() {
3240 let from_new = TileDiffBuilder::new();
3241 let from_default = TileDiffBuilder::default();
3242 assert_eq!(format!("{:?}", from_new), format!("{:?}", from_default));
3243 }
3244
3245 #[test]
3248 fn tile_params_total_tiles_and_cells() {
3249 let params = TileParams {
3250 width: 100,
3251 height: 50,
3252 tile_w: 16,
3253 tile_h: 8,
3254 tiles_x: 7, tiles_y: 7, };
3257 assert_eq!(params.total_tiles(), 49);
3258 assert_eq!(params.total_cells(), 5000);
3259 }
3260
3261 #[test]
3264 fn tile_diff_stats_from_builder() {
3265 let mut builder = TileDiffBuilder::new();
3266 let config = TileDiffConfig::default()
3267 .with_min_cells_for_tiles(0)
3268 .with_dense_tile_ratio(1.1)
3269 .with_dense_cell_ratio(1.1)
3270 .with_max_tiles(usize::MAX / 4);
3271
3272 let w = 32u16;
3273 let h = 16u16;
3274 let dirty_rows = vec![true; h as usize];
3275 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
3276 dirty_bits[0] = 1; let input = TileDiffInput {
3279 width: w,
3280 height: h,
3281 dirty_rows: &dirty_rows,
3282 dirty_bits: &dirty_bits,
3283 dirty_cells: 1,
3284 dirty_all: false,
3285 };
3286
3287 let result = builder.build(&config, input);
3288 match result {
3289 TileDiffBuild::UseTiles(plan) => {
3290 let stats = plan.stats;
3291 assert_eq!(stats.width, w);
3292 assert_eq!(stats.height, h);
3293 assert_eq!(stats.tile_w, 16);
3295 assert_eq!(stats.tile_h, 8);
3296 assert_eq!(stats.tiles_x, 2);
3298 assert_eq!(stats.tiles_y, 2);
3299 assert_eq!(stats.total_tiles, 4);
3300 assert_eq!(stats.dirty_cells, 1);
3301 assert_eq!(stats.dirty_tiles, 1);
3302 assert_eq!(stats.skipped_tiles, 3);
3303 assert!(stats.fallback.is_none());
3304 let copy = stats;
3306 assert_eq!(copy.width, stats.width);
3307 }
3308 _ => unreachable!("expected UseTiles"),
3309 }
3310 }
3311
3312 #[test]
3315 fn tile_size_clamped_to_min_8() {
3316 let mut builder = TileDiffBuilder::new();
3317 let config = TileDiffConfig {
3318 enabled: true,
3319 tile_w: 1, tile_h: 0, skip_clean_rows: false,
3322 min_cells_for_tiles: 0,
3323 dense_cell_ratio: 1.1,
3324 dense_tile_ratio: 1.1,
3325 max_tiles: usize::MAX / 4,
3326 };
3327
3328 let dirty_rows = vec![true; 16];
3329 let mut dirty_bits = vec![0u8; 256]; dirty_bits[0] = 1;
3331
3332 let input = TileDiffInput {
3333 width: 16,
3334 height: 16,
3335 dirty_rows: &dirty_rows,
3336 dirty_bits: &dirty_bits,
3337 dirty_cells: 1,
3338 dirty_all: false,
3339 };
3340
3341 let result = builder.build(&config, input);
3342 match result {
3343 TileDiffBuild::UseTiles(plan) => {
3344 assert_eq!(plan.stats.tile_w, 8);
3345 assert_eq!(plan.stats.tile_h, 8);
3346 }
3347 _ => unreachable!("expected UseTiles"),
3348 }
3349 }
3350
3351 #[test]
3352 fn tile_size_clamped_to_max_64() {
3353 let mut builder = TileDiffBuilder::new();
3354 let config = TileDiffConfig {
3355 enabled: true,
3356 tile_w: 255, tile_h: 100, skip_clean_rows: false,
3359 min_cells_for_tiles: 0,
3360 dense_cell_ratio: 1.1,
3361 dense_tile_ratio: 1.1,
3362 max_tiles: usize::MAX / 4,
3363 };
3364
3365 let dirty_rows = vec![true; 128];
3366 let mut dirty_bits = vec![0u8; 128 * 128];
3367 dirty_bits[0] = 1;
3368
3369 let input = TileDiffInput {
3370 width: 128,
3371 height: 128,
3372 dirty_rows: &dirty_rows,
3373 dirty_bits: &dirty_bits,
3374 dirty_cells: 1,
3375 dirty_all: false,
3376 };
3377
3378 let result = builder.build(&config, input);
3379 match result {
3380 TileDiffBuild::UseTiles(plan) => {
3381 assert_eq!(plan.stats.tile_w, 64);
3382 assert_eq!(plan.stats.tile_h, 64);
3383 }
3384 _ => unreachable!("expected UseTiles"),
3385 }
3386 }
3387
3388 #[test]
3391 fn buffer_diff_default_is_empty() {
3392 let diff: BufferDiff = Default::default();
3393 assert!(diff.is_empty());
3394 assert_eq!(diff.len(), 0);
3395 assert!(diff.last_tile_stats().is_none());
3396 }
3397
3398 #[test]
3399 fn buffer_diff_debug_format() {
3400 let diff = BufferDiff::new();
3401 let dbg = format!("{:?}", diff);
3402 assert!(dbg.contains("BufferDiff"), "Debug: {dbg}");
3403 }
3404
3405 #[test]
3406 fn buffer_diff_clone_preserves_changes() {
3407 let old = Buffer::new(5, 5);
3408 let mut new = Buffer::new(5, 5);
3409 new.set_raw(2, 3, Cell::from_char('X'));
3410 let diff = BufferDiff::compute(&old, &new);
3411 let cloned = diff.clone();
3412 assert_eq!(diff.changes(), cloned.changes());
3413 }
3414
3415 #[test]
3418 fn compute_into_replaces_previous_changes() {
3419 let mut diff = BufferDiff::new();
3420
3421 let old1 = Buffer::new(5, 5);
3422 let mut new1 = Buffer::new(5, 5);
3423 new1.set_raw(0, 0, Cell::from_char('A'));
3424 diff.compute_into(&old1, &new1);
3425 assert_eq!(diff.len(), 1);
3426
3427 let old2 = Buffer::new(3, 3);
3428 let mut new2 = Buffer::new(3, 3);
3429 new2.set_raw(1, 1, Cell::from_char('B'));
3430 new2.set_raw(2, 2, Cell::from_char('C'));
3431 diff.compute_into(&old2, &new2);
3432 assert_eq!(diff.len(), 2);
3433 assert_eq!(diff.changes(), &[(1, 1), (2, 2)]);
3434 }
3435
3436 #[test]
3437 fn compute_into_identical_clears() {
3438 let mut diff = BufferDiff::new();
3439 let old = Buffer::new(5, 5);
3440 let mut new = Buffer::new(5, 5);
3441 new.set_raw(0, 0, Cell::from_char('X'));
3442 diff.compute_into(&old, &new);
3443 assert_eq!(diff.len(), 1);
3444
3445 diff.compute_into(&old, &old);
3446 assert!(diff.is_empty());
3447 }
3448
3449 #[test]
3452 fn compute_dirty_into_no_dirty_rows() {
3453 let old = Buffer::new(10, 10);
3454 let mut new = old.clone();
3455 new.clear_dirty();
3456
3457 let mut diff = BufferDiff::new();
3458 diff.compute_dirty_into(&old, &new);
3459 assert!(diff.is_empty());
3460 }
3461
3462 #[test]
3463 fn compute_dirty_into_reuse() {
3464 let mut diff = BufferDiff::new();
3465
3466 let old = Buffer::new(10, 10);
3467 let mut new1 = old.clone();
3468 new1.clear_dirty();
3469 new1.set_raw(5, 5, Cell::from_char('X'));
3470 diff.compute_dirty_into(&old, &new1);
3471 assert_eq!(diff.len(), 1);
3472
3473 let mut new2 = old.clone();
3474 new2.clear_dirty();
3475 new2.set_raw(3, 3, Cell::from_char('Y'));
3476 new2.set_raw(7, 7, Cell::from_char('Z'));
3477 diff.compute_dirty_into(&old, &new2);
3478 assert_eq!(diff.len(), 2);
3479 assert_eq!(diff.changes(), &[(3, 3), (7, 7)]);
3480 }
3481
3482 #[test]
3485 fn last_tile_stats_none_after_compute_into() {
3486 let mut diff = BufferDiff::new();
3487 let old = Buffer::new(5, 5);
3488 let new = Buffer::new(5, 5);
3489 diff.compute_into(&old, &new);
3490 assert!(diff.last_tile_stats().is_none());
3491 }
3492
3493 #[test]
3494 fn last_tile_stats_some_after_compute_dirty_into() {
3495 let mut diff = BufferDiff::new();
3496 let old = Buffer::new(10, 10);
3497 let mut new = old.clone();
3498 new.set_raw(0, 0, Cell::from_char('A'));
3499 diff.compute_dirty_into(&old, &new);
3500 let stats = diff.last_tile_stats().expect("should have tile stats");
3502 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
3503 }
3504
3505 #[test]
3508 fn tile_config_mut_modifies_behavior() {
3509 let old = Buffer::new(200, 60);
3510 let mut new = old.clone();
3511 new.clear_dirty();
3512 new.set_raw(0, 0, Cell::from_char('X'));
3513
3514 let mut diff = BufferDiff::new();
3516 diff.compute_dirty_into(&old, &new);
3517 let stats = diff.last_tile_stats().expect("stats");
3518 assert!(
3519 stats.fallback.is_none(),
3520 "tiles should be active for 200x60"
3521 );
3522
3523 diff.tile_config_mut().enabled = false;
3525 diff.compute_dirty_into(&old, &new);
3526 let stats = diff.last_tile_stats().expect("stats");
3527 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
3528 }
3529
3530 #[test]
3533 fn row_scan_width_31_below_row_block_size() {
3534 let old = Buffer::new(31, 1);
3535 let mut new = Buffer::new(31, 1);
3536 new.set_raw(0, 0, Cell::from_char('A'));
3537 new.set_raw(15, 0, Cell::from_char('B'));
3538 new.set_raw(30, 0, Cell::from_char('C'));
3539
3540 let diff = BufferDiff::compute(&old, &new);
3541 assert_eq!(diff.len(), 3);
3542 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (30, 0)]);
3543 }
3544
3545 #[test]
3546 fn row_scan_width_32_exact_row_block_size() {
3547 let old = Buffer::new(32, 1);
3548 let mut new = Buffer::new(32, 1);
3549 new.set_raw(0, 0, Cell::from_char('A'));
3550 new.set_raw(15, 0, Cell::from_char('B'));
3551 new.set_raw(31, 0, Cell::from_char('C'));
3552
3553 let diff = BufferDiff::compute(&old, &new);
3554 assert_eq!(diff.len(), 3);
3555 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (31, 0)]);
3556 }
3557
3558 #[test]
3559 fn row_scan_width_33_one_past_row_block_size() {
3560 let old = Buffer::new(33, 1);
3561 let mut new = Buffer::new(33, 1);
3562 new.set_raw(0, 0, Cell::from_char('A'));
3563 new.set_raw(31, 0, Cell::from_char('B'));
3564 new.set_raw(32, 0, Cell::from_char('C'));
3565
3566 let diff = BufferDiff::compute(&old, &new);
3567 assert_eq!(diff.len(), 3);
3568 assert_eq!(diff.changes(), &[(0, 0), (31, 0), (32, 0)]);
3569 }
3570
3571 #[test]
3574 fn dense_cells_exact_threshold_triggers_fallback() {
3575 let mut builder = TileDiffBuilder::new();
3576 let w = 20u16;
3577 let h = 20u16;
3578 let total = (w as usize) * (h as usize); let dirty_count = total / 4; let config = TileDiffConfig {
3582 enabled: true,
3583 tile_w: 8,
3584 tile_h: 8,
3585 skip_clean_rows: false,
3586 min_cells_for_tiles: 0,
3587 dense_cell_ratio: 0.25,
3588 dense_tile_ratio: 1.1,
3589 max_tiles: usize::MAX / 4,
3590 };
3591
3592 let dirty_rows = vec![true; h as usize];
3593 let dirty_bits = vec![1u8; total];
3594
3595 let input = TileDiffInput {
3596 width: w,
3597 height: h,
3598 dirty_rows: &dirty_rows,
3599 dirty_bits: &dirty_bits,
3600 dirty_cells: dirty_count,
3601 dirty_all: false,
3602 };
3603
3604 let result = builder.build(&config, input);
3605 assert!(matches!(
3606 result,
3607 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
3608 ));
3609 }
3610
3611 #[test]
3612 fn dense_cells_just_below_threshold_passes() {
3613 let mut builder = TileDiffBuilder::new();
3614 let w = 20u16;
3615 let h = 20u16;
3616 let total = (w as usize) * (h as usize); let dirty_count = total / 4 - 1; let config = TileDiffConfig {
3620 enabled: true,
3621 tile_w: 8,
3622 tile_h: 8,
3623 skip_clean_rows: false,
3624 min_cells_for_tiles: 0,
3625 dense_cell_ratio: 0.25,
3626 dense_tile_ratio: 1.1,
3627 max_tiles: usize::MAX / 4,
3628 };
3629
3630 let dirty_rows = vec![true; h as usize];
3631 let mut dirty_bits = vec![0u8; total];
3632 for bit in dirty_bits.iter_mut().take(dirty_count) {
3633 *bit = 1;
3634 }
3635
3636 let input = TileDiffInput {
3637 width: w,
3638 height: h,
3639 dirty_rows: &dirty_rows,
3640 dirty_bits: &dirty_bits,
3641 dirty_cells: dirty_count,
3642 dirty_all: false,
3643 };
3644
3645 let result = builder.build(&config, input);
3646 match &result {
3647 TileDiffBuild::Fallback(stats) => {
3648 assert_ne!(
3649 stats.fallback,
3650 Some(TileDiffFallback::DenseCells),
3651 "should not trigger DenseCells below 25%: {:?}",
3652 stats.fallback
3653 );
3654 }
3655 TileDiffBuild::UseTiles(_) => { }
3656 }
3657 }
3658
3659 #[test]
3662 fn switch_between_compute_and_dirty() {
3663 let mut diff = BufferDiff::new();
3664 let old = Buffer::new(10, 10);
3665 let mut new = Buffer::new(10, 10);
3666 new.set_raw(3, 3, Cell::from_char('X'));
3667
3668 diff.compute_into(&old, &new);
3669 assert_eq!(diff.len(), 1);
3670 assert!(diff.last_tile_stats().is_none());
3671
3672 diff.compute_dirty_into(&old, &new);
3673 assert_eq!(diff.len(), 1);
3674 assert!(diff.last_tile_stats().is_some());
3675
3676 diff.compute_into(&old, &new);
3677 assert_eq!(diff.len(), 1);
3678 assert!(diff.last_tile_stats().is_none());
3679 }
3680
3681 #[test]
3684 #[should_panic(expected = "buffer heights must match")]
3685 fn compute_panics_on_height_mismatch() {
3686 let old = Buffer::new(5, 5);
3687 let new = Buffer::new(5, 4);
3688 let _ = BufferDiff::compute(&old, &new);
3689 }
3690
3691 #[test]
3692 #[should_panic(expected = "buffer widths must match")]
3693 fn compute_dirty_panics_on_width_mismatch() {
3694 let old = Buffer::new(5, 5);
3695 let new = Buffer::new(4, 5);
3696 let _ = BufferDiff::compute_dirty(&old, &new);
3697 }
3698
3699 #[test]
3700 #[should_panic(expected = "buffer heights must match")]
3701 fn compute_dirty_panics_on_height_mismatch() {
3702 let old = Buffer::new(5, 5);
3703 let new = Buffer::new(5, 4);
3704 let _ = BufferDiff::compute_dirty(&old, &new);
3705 }
3706
3707 #[test]
3710 fn tile_diff_input_debug_copy() {
3711 let dirty_rows = vec![true; 2];
3712 let dirty_bits = vec![0u8; 4];
3713 let input = TileDiffInput {
3714 width: 2,
3715 height: 2,
3716 dirty_rows: &dirty_rows,
3717 dirty_bits: &dirty_bits,
3718 dirty_cells: 0,
3719 dirty_all: false,
3720 };
3721 let dbg = format!("{:?}", input);
3722 assert!(dbg.contains("TileDiffInput"), "Debug: {dbg}");
3723 let copy = input; assert_eq!(copy.width, input.width);
3725 }
3726
3727 #[test]
3728 fn tile_diff_build_debug() {
3729 let mut builder = TileDiffBuilder::new();
3730 let config = TileDiffConfig::default();
3731 let dirty_rows = vec![true; 1];
3732 let dirty_bits = vec![0u8; 4];
3733 let input = TileDiffInput {
3734 width: 4,
3735 height: 1,
3736 dirty_rows: &dirty_rows,
3737 dirty_bits: &dirty_bits,
3738 dirty_cells: 0,
3739 dirty_all: false,
3740 };
3741 let result = builder.build(&config, input);
3742 let dbg = format!("{:?}", result);
3743 assert!(dbg.contains("Fallback"), "Debug: {dbg}");
3744 }
3745
3746 #[test]
3749 fn full_diff_runs_one_per_row() {
3750 let diff = BufferDiff::full(10, 3);
3751 let runs = diff.runs();
3752 assert_eq!(runs.len(), 3);
3753 for (i, run) in runs.iter().enumerate() {
3754 assert_eq!(run.y, i as u16);
3755 assert_eq!(run.x0, 0);
3756 assert_eq!(run.x1, 9);
3757 assert_eq!(run.len(), 10);
3758 }
3759 }
3760
3761 #[test]
3764 fn dirty_diff_skips_false_positive_rows() {
3765 let old = Buffer::new(10, 5);
3766 let mut new = old.clone();
3767 new.clear_dirty();
3768
3769 for y in 0..5u16 {
3771 new.set_raw(0, y, Cell::default());
3772 }
3773 new.set_raw(5, 1, Cell::from_char('A'));
3775 new.set_raw(7, 3, Cell::from_char('B'));
3776
3777 let diff = BufferDiff::compute_dirty(&old, &new);
3778 assert_eq!(diff.len(), 2);
3779 assert!(diff.changes().contains(&(5, 1)));
3780 assert!(diff.changes().contains(&(7, 3)));
3781 }
3782
3783 #[test]
3786 fn bg_only_change_detected() {
3787 let old = Buffer::new(5, 1);
3788 let mut new = Buffer::new(5, 1);
3789 new.set_raw(2, 0, Cell::default().with_bg(PackedRgba::rgb(0, 0, 255)));
3790
3791 let diff = BufferDiff::compute(&old, &new);
3792 assert_eq!(diff.len(), 1);
3793 assert_eq!(diff.changes(), &[(2, 0)]);
3794 }
3795
3796 #[test]
3799 fn changes_at_buffer_corners() {
3800 let w = 100u16;
3801 let h = 50u16;
3802 let old = Buffer::new(w, h);
3803 let mut new = Buffer::new(w, h);
3804 new.set_raw(0, 0, Cell::from_char('A'));
3805 new.set_raw(w - 1, h - 1, Cell::from_char('Z'));
3806
3807 let diff = BufferDiff::compute(&old, &new);
3808 assert_eq!(diff.len(), 2);
3809 assert_eq!(diff.changes()[0], (0, 0));
3810 assert_eq!(diff.changes()[1], (w - 1, h - 1));
3811
3812 let runs = diff.runs();
3813 assert_eq!(runs.len(), 2);
3814 assert_eq!(runs[0], ChangeRun::new(0, 0, 0));
3815 assert_eq!(runs[1], ChangeRun::new(h - 1, w - 1, w - 1));
3816 }
3817
3818 #[test]
3821 fn tile_params_debug_copy() {
3822 let params = TileParams {
3823 width: 80,
3824 height: 24,
3825 tile_w: 16,
3826 tile_h: 8,
3827 tiles_x: 5,
3828 tiles_y: 3,
3829 };
3830 let dbg = format!("{:?}", params);
3831 assert!(dbg.contains("TileParams"), "Debug: {dbg}");
3832 let copy = params; assert_eq!(copy.width, params.width);
3834 let cloned: TileParams = params; assert_eq!(cloned.tiles_x, params.tiles_x);
3836 }
3837
3838 #[test]
3841 fn tile_diff_stats_debug_copy() {
3842 let mut builder = TileDiffBuilder::new();
3843 let config = TileDiffConfig::default();
3844 let dirty_rows = vec![true; 1];
3845 let dirty_bits = vec![0u8; 1];
3846 let input = TileDiffInput {
3847 width: 1,
3848 height: 1,
3849 dirty_rows: &dirty_rows,
3850 dirty_bits: &dirty_bits,
3851 dirty_cells: 0,
3852 dirty_all: false,
3853 };
3854 let result = builder.build(&config, input);
3855 match result {
3856 TileDiffBuild::Fallback(stats) => {
3857 let dbg = format!("{:?}", stats);
3858 assert!(dbg.contains("TileDiffStats"), "Debug: {dbg}");
3859 let copy = stats;
3860 assert_eq!(copy.width, stats.width);
3861 let cloned: TileDiffStats = stats; assert_eq!(cloned.height, stats.height);
3863 }
3864 _ => unreachable!("expected Fallback for 1x1"),
3865 }
3866 }
3867
3868 #[test]
3873 fn compute_dirty_with_targeted_spans() {
3874 let width = 100u16;
3877 let height = 10u16;
3878 let old = Buffer::new(width, height);
3879 let mut new = old.clone();
3880 new.clear_dirty();
3881
3882 new.set_raw(10, 3, Cell::from_char('A'));
3884 new.set_raw(11, 3, Cell::from_char('B'));
3885 new.set_raw(50, 7, Cell::from_char('C'));
3886
3887 let full = BufferDiff::compute(&old, &new);
3888 let dirty = BufferDiff::compute_dirty(&old, &new);
3889
3890 assert_eq!(full.changes(), dirty.changes());
3891 assert_eq!(dirty.len(), 3);
3892 assert!(dirty.changes().contains(&(10, 3)));
3893 assert!(dirty.changes().contains(&(11, 3)));
3894 assert!(dirty.changes().contains(&(50, 7)));
3895 }
3896
3897 #[test]
3898 fn compute_dirty_spans_on_multiple_rows() {
3899 let width = 80u16;
3901 let height = 5u16;
3902 let old = Buffer::new(width, height);
3903 let mut new = old.clone();
3904 new.clear_dirty();
3905
3906 new.set_raw(0, 0, Cell::from_char('A'));
3908 new.set_raw(1, 0, Cell::from_char('B'));
3909 new.set_raw(40, 2, Cell::from_char('C'));
3911 new.set_raw(79, 4, Cell::from_char('D'));
3913
3914 let full = BufferDiff::compute(&old, &new);
3915 let dirty = BufferDiff::compute_dirty(&old, &new);
3916
3917 assert_eq!(full.changes(), dirty.changes());
3918 assert_eq!(dirty.len(), 4);
3919 }
3920
3921 #[test]
3922 fn compute_dirty_span_with_false_positive_row() {
3923 let old = Buffer::new(20, 3);
3925 let mut new = old.clone();
3926 new.clear_dirty();
3927
3928 new.set_raw(5, 1, Cell::default());
3930 new.set_raw(10, 2, Cell::from_char('X'));
3932
3933 let dirty = BufferDiff::compute_dirty(&old, &new);
3934 assert_eq!(dirty.len(), 1);
3935 assert_eq!(dirty.changes(), &[(10, 2)]);
3936 }
3937
3938 #[test]
3939 fn compute_dirty_many_spans_per_row() {
3940 let width = 200u16;
3942 let height = 1u16;
3943 let old = Buffer::new(width, height);
3944 let mut new = old.clone();
3945 new.clear_dirty();
3946
3947 let positions: Vec<u16> = vec![5, 20, 35, 50, 65, 80, 95, 110, 125, 140];
3949 for &x in &positions {
3950 new.set_raw(x, 0, Cell::from_char('X'));
3951 }
3952
3953 let full = BufferDiff::compute(&old, &new);
3954 let dirty = BufferDiff::compute_dirty(&old, &new);
3955
3956 assert_eq!(full.changes(), dirty.changes());
3957 assert_eq!(dirty.len(), positions.len());
3958 }
3959
3960 #[test]
3965 fn tile_diff_with_dirty_spans_matches_full() {
3966 let width = 200u16;
3968 let height = 60u16;
3969 let old = Buffer::new(width, height);
3970 let mut new = old.clone();
3971 new.clear_dirty();
3972
3973 new.set_raw(5, 2, Cell::from_char('A'));
3975 new.set_raw(100, 30, Cell::from_char('B'));
3976 new.set_raw(195, 55, Cell::from_char('C'));
3977
3978 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
3979 let full = BufferDiff::compute(&old, &new);
3980
3981 assert!(stats.fallback.is_none(), "tile path should be used");
3982 assert_eq!(
3983 stats.sat_build_cells, 3,
3984 "span-aware tile build should only scan covered span cells"
3985 );
3986 assert_eq!(full.changes(), dirty_diff.changes());
3987 assert_eq!(dirty_diff.len(), 3);
3988 }
3989
3990 #[test]
3991 fn tile_diff_with_spans_straddling_tile_boundary() {
3992 let width = 200u16;
3994 let height = 60u16;
3995 let old = Buffer::new(width, height);
3996 let mut new = old.clone();
3997 new.clear_dirty();
3998
3999 new.set_raw(6, 0, Cell::from_char('A'));
4001 new.set_raw(7, 0, Cell::from_char('B'));
4002 new.set_raw(8, 0, Cell::from_char('C'));
4003 new.set_raw(9, 0, Cell::from_char('D'));
4004
4005 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
4006 let full = BufferDiff::compute(&old, &new);
4007
4008 assert!(stats.fallback.is_none());
4009 assert_eq!(full.changes(), dirty_diff.changes());
4010 assert_eq!(dirty_diff.len(), 4);
4011 }
4012
4013 #[test]
4014 fn tile_diff_single_dirty_tile_skips_others() {
4015 let width = 200u16;
4017 let height = 60u16;
4018 let old = Buffer::new(width, height);
4019 let mut new = old.clone();
4020 new.clear_dirty();
4021
4022 new.set_raw(3, 3, Cell::from_char('X'));
4024
4025 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
4026
4027 assert!(stats.fallback.is_none());
4028 assert_eq!(dirty_diff.len(), 1);
4029 assert!(stats.skipped_tiles > 0, "should skip clean tiles");
4030 assert_eq!(stats.dirty_tiles, 1, "only one tile should be dirty");
4031 }
4032
4033 #[test]
4034 fn accumulate_tile_counts_range_single_cell_range() {
4035 let mut tile_counts = vec![0u32; 4];
4036 let dirty_bits = vec![0, 0, 1, 0, 0, 0, 0, 0];
4037 let mut scanned_cells = 0usize;
4038
4039 accumulate_tile_counts_range(
4040 &mut tile_counts,
4041 &dirty_bits,
4042 0,
4043 4,
4044 2,
4045 2..3,
4046 &mut scanned_cells,
4047 )
4048 .unwrap();
4049
4050 assert_eq!(scanned_cells, 1);
4051 assert_eq!(tile_counts, vec![0, 1, 0, 0]);
4052 }
4053
4054 #[test]
4055 fn accumulate_tile_counts_range_single_tile_multi_cell_range() {
4056 let mut tile_counts = vec![0u32; 4];
4057 let dirty_bits = vec![0, 1, 0, 1, 1, 0, 0, 0];
4058 let mut scanned_cells = 0usize;
4059
4060 accumulate_tile_counts_range(
4061 &mut tile_counts,
4062 &dirty_bits,
4063 0,
4064 4,
4065 2,
4066 4..6,
4067 &mut scanned_cells,
4068 )
4069 .unwrap();
4070
4071 assert_eq!(scanned_cells, 2);
4072 assert_eq!(tile_counts, vec![0, 0, 1, 0]);
4073 }
4074
4075 #[test]
4080 fn tile_diff_plan_fields_accessible() {
4081 let mut builder = TileDiffBuilder::new();
4082 let config = TileDiffConfig::default()
4083 .with_min_cells_for_tiles(0)
4084 .with_dense_tile_ratio(1.1)
4085 .with_dense_cell_ratio(1.1)
4086 .with_max_tiles(usize::MAX / 4);
4087
4088 let w = 32u16;
4089 let h = 16u16;
4090 let dirty_rows = vec![true; h as usize];
4091 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
4092 dirty_bits[0] = 1; dirty_bits[8 + 8 * w as usize] = 1; let input = TileDiffInput {
4097 width: w,
4098 height: h,
4099 dirty_rows: &dirty_rows,
4100 dirty_bits: &dirty_bits,
4101 dirty_cells: 2,
4102 dirty_all: false,
4103 };
4104
4105 let result = builder.build(&config, input);
4106 match result {
4107 TileDiffBuild::UseTiles(plan) => {
4108 assert_eq!(plan.params.width, w);
4110 assert_eq!(plan.params.height, h);
4111 assert!(plan.params.tile_w >= 8);
4112 assert!(plan.params.tile_h >= 8);
4113 assert!(plan.params.tiles_x > 0);
4114 assert!(plan.params.tiles_y > 0);
4115
4116 assert_eq!(plan.dirty_tiles.len(), plan.params.total_tiles());
4118 let dirty_count: usize = plan.dirty_tiles.iter().filter(|&&d| d).count();
4119 assert_eq!(dirty_count, plan.stats.dirty_tiles);
4120
4121 assert_eq!(plan.tile_counts.len(), plan.params.total_tiles());
4123
4124 let expected_sat_len = (plan.params.tiles_x + 1) * (plan.params.tiles_y + 1);
4126 assert_eq!(plan.sat.len(), expected_sat_len);
4127
4128 let sat_w = plan.params.tiles_x + 1;
4130 for tx in 0..sat_w {
4131 assert_eq!(plan.sat[tx], 0, "SAT top border should be 0");
4132 }
4133 for ty in 0..plan.params.tiles_y + 1 {
4134 assert_eq!(plan.sat[ty * sat_w], 0, "SAT left border should be 0");
4135 }
4136 }
4137 _ => unreachable!("expected UseTiles"),
4138 }
4139 }
4140
4141 #[test]
4146 fn dense_tiles_exact_threshold_triggers_fallback() {
4147 let mut builder = TileDiffBuilder::new();
4148 let w = 32u16;
4149 let h = 32u16;
4150 let total_cells = (w as usize) * (h as usize);
4154 let config = TileDiffConfig {
4155 enabled: true,
4156 tile_w: 8,
4157 tile_h: 8,
4158 skip_clean_rows: false,
4159 min_cells_for_tiles: 0,
4160 dense_cell_ratio: 1.1,
4161 dense_tile_ratio: 0.5,
4162 max_tiles: usize::MAX / 4,
4163 };
4164
4165 let dirty_rows = vec![true; h as usize];
4166 let mut dirty_bits = vec![0u8; total_cells];
4167 for tile_y in 0..2 {
4170 for tile_x in 0..4 {
4171 let x = tile_x * 8;
4172 let y = tile_y * 8;
4173 dirty_bits[y * w as usize + x] = 1;
4174 }
4175 }
4176
4177 let input = TileDiffInput {
4178 width: w,
4179 height: h,
4180 dirty_rows: &dirty_rows,
4181 dirty_bits: &dirty_bits,
4182 dirty_cells: 8,
4183 dirty_all: false,
4184 };
4185
4186 let result = builder.build(&config, input);
4187 assert!(
4188 matches!(
4189 result,
4190 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseTiles)
4191 ),
4192 "8 of 16 tiles dirty (50%) should trigger DenseTiles with threshold 0.5"
4193 );
4194 }
4195
4196 #[test]
4197 fn dense_tiles_just_below_threshold_passes() {
4198 let mut builder = TileDiffBuilder::new();
4199 let w = 32u16;
4200 let h = 32u16;
4201 let total_cells = (w as usize) * (h as usize);
4204 let config = TileDiffConfig {
4205 enabled: true,
4206 tile_w: 8,
4207 tile_h: 8,
4208 skip_clean_rows: false,
4209 min_cells_for_tiles: 0,
4210 dense_cell_ratio: 1.1,
4211 dense_tile_ratio: 0.5,
4212 max_tiles: usize::MAX / 4,
4213 };
4214
4215 let dirty_rows = vec![true; h as usize];
4216 let mut dirty_bits = vec![0u8; total_cells];
4217 for tile_idx in 0..7 {
4219 let tile_x = tile_idx % 4;
4220 let tile_y = tile_idx / 4;
4221 let x = tile_x * 8;
4222 let y = tile_y * 8;
4223 dirty_bits[y * w as usize + x] = 1;
4224 }
4225
4226 let input = TileDiffInput {
4227 width: w,
4228 height: h,
4229 dirty_rows: &dirty_rows,
4230 dirty_bits: &dirty_bits,
4231 dirty_cells: 7,
4232 dirty_all: false,
4233 };
4234
4235 let result = builder.build(&config, input);
4236 assert!(
4237 matches!(result, TileDiffBuild::UseTiles(_)),
4238 "7 of 16 tiles dirty (43.75%) should use tiles with threshold 0.5"
4239 );
4240 }
4241
4242 #[test]
4247 fn span_diagnostics_with_no_dirty_spans() {
4248 let mut buf = Buffer::new(20, 3);
4249 buf.clear_dirty();
4250 let diag = span_diagnostics(&buf);
4251 assert!(
4253 diag.contains("stats="),
4254 "diagnostics should include stats: {diag}"
4255 );
4256 }
4257
4258 #[test]
4259 fn span_diagnostics_with_dirty_cells() {
4260 let mut buf = Buffer::new(20, 3);
4261 buf.clear_dirty();
4262 buf.set_raw(5, 1, Cell::from_char('X'));
4263 let diag = span_diagnostics(&buf);
4264 assert!(
4265 diag.contains("stats="),
4266 "diagnostics should include stats: {diag}"
4267 );
4268 }
4269
4270 #[test]
4271 fn span_diagnostics_with_full_row() {
4272 let mut buf = Buffer::new(20, 2);
4273 buf.clear_dirty();
4274 for x in 0..20u16 {
4276 buf.set_raw(x, 0, Cell::from_char('X'));
4277 }
4278 let diag = span_diagnostics(&buf);
4279 assert!(
4281 diag.contains("stats="),
4282 "diagnostics should include stats: {diag}"
4283 );
4284 }
4285
4286 #[test]
4291 fn compute_dirty_matches_full_for_single_row_buffer() {
4292 let old = Buffer::new(50, 1);
4293 let mut new = old.clone();
4294 new.set_raw(25, 0, Cell::from_char('M'));
4295
4296 let full = BufferDiff::compute(&old, &new);
4297 let dirty = BufferDiff::compute_dirty(&old, &new);
4298
4299 assert_eq!(full.changes(), dirty.changes());
4300 }
4301
4302 #[test]
4303 fn compute_dirty_matches_full_for_single_column_buffer() {
4304 let old = Buffer::new(1, 50);
4305 let mut new = old.clone();
4306 new.set_raw(0, 25, Cell::from_char('M'));
4307
4308 let full = BufferDiff::compute(&old, &new);
4309 let dirty = BufferDiff::compute_dirty(&old, &new);
4310
4311 assert_eq!(full.changes(), dirty.changes());
4312 }
4313
4314 #[test]
4315 fn tile_config_chain_returns_same_type() {
4316 let config = TileDiffConfig::default()
4318 .with_enabled(true)
4319 .with_tile_size(16, 16)
4320 .with_min_cells_for_tiles(1000)
4321 .with_skip_clean_rows(true)
4322 .with_dense_cell_ratio(0.3)
4323 .with_dense_tile_ratio(0.7)
4324 .with_max_tiles(2048);
4325
4326 assert!(config.enabled);
4327 assert_eq!(config.tile_w, 16);
4328 assert_eq!(config.tile_h, 16);
4329 assert_eq!(config.min_cells_for_tiles, 1000);
4330 assert!(config.skip_clean_rows);
4331 assert!((config.dense_cell_ratio - 0.3).abs() < f64::EPSILON);
4332 assert!((config.dense_tile_ratio - 0.7).abs() < f64::EPSILON);
4333 assert_eq!(config.max_tiles, 2048);
4334 }
4335
4336 #[test]
4337 fn runs_into_reuse_across_multiple_diffs() {
4338 let mut runs_buf: Vec<ChangeRun> = Vec::new();
4340
4341 let old1 = Buffer::new(10, 2);
4343 let mut new1 = Buffer::new(10, 2);
4344 new1.set_raw(0, 0, Cell::from_char('A'));
4345 new1.set_raw(1, 0, Cell::from_char('B'));
4346 let diff1 = BufferDiff::compute(&old1, &new1);
4347 diff1.runs_into(&mut runs_buf);
4348 assert_eq!(runs_buf.len(), 1);
4349 assert_eq!(runs_buf[0], ChangeRun::new(0, 0, 1));
4350
4351 let old2 = Buffer::new(5, 3);
4353 let mut new2 = Buffer::new(5, 3);
4354 new2.set_raw(2, 1, Cell::from_char('X'));
4355 new2.set_raw(4, 2, Cell::from_char('Y'));
4356 let diff2 = BufferDiff::compute(&old2, &new2);
4357 diff2.runs_into(&mut runs_buf);
4358 assert_eq!(runs_buf.len(), 2);
4359 assert_eq!(runs_buf[0], ChangeRun::new(1, 2, 2));
4360 assert_eq!(runs_buf[1], ChangeRun::new(2, 4, 4));
4361 }
4362
4363 #[test]
4364 fn full_diff_zero_dimensions_runs_empty() {
4365 let diff_w0 = BufferDiff::full(0, 5);
4366 assert!(diff_w0.runs().is_empty());
4367
4368 let diff_h0 = BufferDiff::full(5, 0);
4369 assert!(diff_h0.runs().is_empty());
4370
4371 let diff_both = BufferDiff::full(0, 0);
4372 assert!(diff_both.runs().is_empty());
4373 }
4374
4375 #[test]
4376 fn change_run_max_u16_values() {
4377 let run = ChangeRun::new(u16::MAX, 1, u16::MAX);
4379 assert_eq!(run.y, u16::MAX);
4380 assert_eq!(run.x0, 1);
4381 assert_eq!(run.x1, u16::MAX);
4382 assert_eq!(run.len(), usize::from(u16::MAX)); let run2 = ChangeRun::new(u16::MAX, u16::MAX, u16::MAX);
4386 assert_eq!(run2.len(), 1);
4387 }
4388
4389 #[test]
4390 fn tile_diff_equivalence_adjacent_tiles() {
4391 let width = 200u16;
4393 let height = 60u16;
4394 let old = Buffer::new(width, height);
4395 let mut new = old.clone();
4396 new.clear_dirty();
4397
4398 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");
4406 }
4407}
4408
4409#[cfg(test)]
4410mod proptests {
4411 use super::*;
4412 use crate::cell::Cell;
4413 use ftui_core::geometry::Rect;
4414 use proptest::prelude::*;
4415
4416 #[test]
4418 fn diff_apply_produces_target() {
4419 proptest::proptest!(|(
4420 width in 5u16..50,
4421 height in 5u16..30,
4422 num_changes in 0usize..200,
4423 )| {
4424 let old = Buffer::new(width, height);
4426
4427 let mut new = old.clone();
4429 for i in 0..num_changes {
4430 let x = (i * 7 + 3) as u16 % width;
4431 let y = (i * 11 + 5) as u16 % height;
4432 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4433 new.set_raw(x, y, Cell::from_char(ch));
4434 }
4435
4436 let diff = BufferDiff::compute(&old, &new);
4438
4439 let mut result = old.clone();
4441 for (x, y) in diff.iter() {
4442 let cell = *new.get_unchecked(x, y);
4443 result.set_raw(x, y, cell);
4444 }
4445
4446 for y in 0..height {
4448 for x in 0..width {
4449 let result_cell = result.get_unchecked(x, y);
4450 let new_cell = new.get_unchecked(x, y);
4451 prop_assert!(
4452 result_cell.bits_eq(new_cell),
4453 "Mismatch at ({}, {})",
4454 x,
4455 y
4456 );
4457 }
4458 }
4459 });
4460 }
4461
4462 #[test]
4464 fn identical_buffers_empty_diff() {
4465 proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
4466 let buf = Buffer::new(width, height);
4467 let diff = BufferDiff::compute(&buf, &buf);
4468 prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
4469 });
4470 }
4471
4472 #[test]
4474 fn diff_contains_only_real_changes() {
4475 proptest::proptest!(|(
4476 width in 5u16..50,
4477 height in 5u16..30,
4478 num_changes in 0usize..100,
4479 )| {
4480 let old = Buffer::new(width, height);
4481 let mut new = old.clone();
4482
4483 for i in 0..num_changes {
4484 let x = (i * 7 + 3) as u16 % width;
4485 let y = (i * 11 + 5) as u16 % height;
4486 new.set_raw(x, y, Cell::from_char('X'));
4487 }
4488
4489 let diff = BufferDiff::compute(&old, &new);
4490
4491 for (x, y) in diff.iter() {
4493 let old_cell = old.get_unchecked(x, y);
4494 let new_cell = new.get_unchecked(x, y);
4495 prop_assert!(
4496 !old_cell.bits_eq(new_cell),
4497 "Diff includes unchanged cell at ({}, {})",
4498 x,
4499 y
4500 );
4501 }
4502 });
4503 }
4504
4505 #[test]
4507 fn runs_are_contiguous() {
4508 proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
4509 let old = Buffer::new(width, height);
4510 let mut new = old.clone();
4511
4512 for y in 0..height.min(5) {
4514 for x in 0..width.min(10) {
4515 new.set_raw(x, y, Cell::from_char('#'));
4516 }
4517 }
4518
4519 let diff = BufferDiff::compute(&old, &new);
4520 let runs = diff.runs();
4521
4522 for run in runs {
4524 prop_assert!(run.x1 >= run.x0, "Run has invalid range");
4525 prop_assert!(!run.is_empty(), "Run should not be empty");
4526
4527 for x in run.x0..=run.x1 {
4529 let old_cell = old.get_unchecked(x, run.y);
4530 let new_cell = new.get_unchecked(x, run.y);
4531 prop_assert!(
4532 !old_cell.bits_eq(new_cell),
4533 "Run includes unchanged cell at ({}, {})",
4534 x,
4535 run.y
4536 );
4537 }
4538 }
4539 });
4540 }
4541
4542 #[test]
4544 fn runs_cover_all_changes() {
4545 proptest::proptest!(|(
4546 width in 10u16..60,
4547 height in 5u16..30,
4548 num_changes in 1usize..100,
4549 )| {
4550 let old = Buffer::new(width, height);
4551 let mut new = old.clone();
4552
4553 for i in 0..num_changes {
4554 let x = (i * 13 + 7) as u16 % width;
4555 let y = (i * 17 + 3) as u16 % height;
4556 new.set_raw(x, y, Cell::from_char('X'));
4557 }
4558
4559 let diff = BufferDiff::compute(&old, &new);
4560 let runs = diff.runs();
4561
4562 let mut run_cells: std::collections::HashSet<(u16, u16)> =
4564 std::collections::HashSet::new();
4565 for run in &runs {
4566 for x in run.x0..=run.x1 {
4567 let was_new = run_cells.insert((x, run.y));
4568 prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
4569 }
4570 }
4571
4572 for (x, y) in diff.iter() {
4574 prop_assert!(
4575 run_cells.contains(&(x, y)),
4576 "Change at ({}, {}) not covered by runs",
4577 x,
4578 y
4579 );
4580 }
4581
4582 prop_assert_eq!(
4583 run_cells.len(),
4584 diff.len(),
4585 "Run cell count should match diff change count"
4586 );
4587 });
4588 }
4589
4590 #[test]
4594 fn block_scan_matches_scalar() {
4595 proptest::proptest!(|(
4596 width in 1u16..200,
4597 height in 1u16..20,
4598 num_changes in 0usize..200,
4599 )| {
4600 use crate::cell::PackedRgba;
4601
4602 let old = Buffer::new(width, height);
4603 let mut new = Buffer::new(width, height);
4604
4605 for i in 0..num_changes {
4606 let x = (i * 13 + 7) as u16 % width;
4607 let y = (i * 17 + 3) as u16 % height;
4608 let fg = PackedRgba::rgb(
4609 ((i * 31) % 256) as u8,
4610 ((i * 47) % 256) as u8,
4611 ((i * 71) % 256) as u8,
4612 );
4613 new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4614 }
4615
4616 let diff = BufferDiff::compute(&old, &new);
4617
4618 let mut scalar_changes = Vec::new();
4620 for y in 0..height {
4621 for x in 0..width {
4622 let old_cell = old.get_unchecked(x, y);
4623 let new_cell = new.get_unchecked(x, y);
4624 if !old_cell.bits_eq(new_cell) {
4625 scalar_changes.push((x, y));
4626 }
4627 }
4628 }
4629
4630 prop_assert_eq!(
4631 diff.changes(),
4632 &scalar_changes[..],
4633 "Block scan should match scalar scan"
4634 );
4635 });
4636 }
4637
4638 #[test]
4644 fn property_diff_equivalence() {
4645 proptest::proptest!(|(
4646 width in 1u16..120,
4647 height in 1u16..40,
4648 num_changes in 0usize..300,
4649 )| {
4650 let old = Buffer::new(width, height);
4651 let mut new = Buffer::new(width, height);
4652
4653 for i in 0..num_changes {
4655 let x = (i * 13 + 7) as u16 % width;
4656 let y = (i * 17 + 3) as u16 % height;
4657 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4658 let fg = crate::cell::PackedRgba::rgb(
4659 ((i * 31) % 256) as u8,
4660 ((i * 47) % 256) as u8,
4661 ((i * 71) % 256) as u8,
4662 );
4663 new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
4664 }
4665
4666 let full = BufferDiff::compute(&old, &new);
4667 let dirty = BufferDiff::compute_dirty(&old, &new);
4668
4669 prop_assert_eq!(
4670 full.changes(),
4671 dirty.changes(),
4672 "dirty diff must match full diff (width={}, height={}, changes={})",
4673 width,
4674 height,
4675 num_changes
4676 );
4677
4678 let full_runs = full.runs();
4680 let dirty_runs = dirty.runs();
4681 prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
4682 for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
4683 prop_assert_eq!(fr, dr, "run mismatch");
4684 }
4685 });
4686 }
4687
4688 #[test]
4691 fn property_diff_equivalence_complex_spans() {
4692 proptest::proptest!(|(
4693 width in 10u16..100,
4694 height in 10u16..50,
4695 ops in proptest::collection::vec(
4696 prop_oneof![
4697 (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
4699 (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
4701 ],
4702 1..50
4703 )
4704 )| {
4705 let old = Buffer::new(width, height);
4706 let mut new = Buffer::new(width, height);
4707
4708 new.clear_dirty();
4710
4711 for (op_type, x, y, ch) in ops {
4712 let x = x % width;
4713 let y = y % height;
4714 let cell = Cell::from_char(ch);
4715
4716 match op_type {
4717 0 => new.set(x, y, cell),
4718 1 => {
4719 let w = ((x + 10).min(width) - x).max(1);
4721 let h = ((y + 5).min(height) - y).max(1);
4722 new.fill(Rect::new(x, y, w, h), cell);
4723 }
4724 _ => unreachable!(),
4725 }
4726 }
4727
4728 let full = BufferDiff::compute(&old, &new);
4729 let dirty = BufferDiff::compute_dirty(&old, &new);
4730
4731 prop_assert_eq!(
4732 full.changes(),
4733 dirty.changes(),
4734 "dirty diff (spans) must match full diff; {}",
4735 super::span_diagnostics(&new)
4736 );
4737 });
4738 }
4739
4740 #[test]
4748 fn diff_is_idempotent() {
4749 proptest::proptest!(|(
4750 width in 5u16..60,
4751 height in 5u16..30,
4752 num_changes in 0usize..100,
4753 )| {
4754 let mut buf_a = Buffer::new(width, height);
4755 let mut buf_b = Buffer::new(width, height);
4756
4757 for i in 0..num_changes {
4759 let x = (i * 13 + 7) as u16 % width;
4760 let y = (i * 17 + 3) as u16 % height;
4761 buf_b.set_raw(x, y, Cell::from_char('X'));
4762 }
4763
4764 let diff = BufferDiff::compute(&buf_a, &buf_b);
4766
4767 for (x, y) in diff.iter() {
4769 let cell = *buf_b.get_unchecked(x, y);
4770 buf_a.set_raw(x, y, cell);
4771 }
4772
4773 let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
4775 prop_assert!(
4776 diff_after_first.is_empty(),
4777 "After applying diff once, buffers should be identical (diff was {} changes)",
4778 diff_after_first.len()
4779 );
4780
4781 let before_second = buf_a.clone();
4783 for (x, y) in diff.iter() {
4784 let cell = *buf_b.get_unchecked(x, y);
4785 buf_a.set_raw(x, y, cell);
4786 }
4787
4788 let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
4790 prop_assert!(
4791 diff_after_second.is_empty(),
4792 "Second diff application should be a no-op"
4793 );
4794 });
4795 }
4796
4797 #[test]
4810 fn no_ghosting_after_clear() {
4811 proptest::proptest!(|(
4812 width in 10u16..80,
4813 height in 5u16..30,
4814 num_content_cells in 1usize..200,
4815 )| {
4816 let old = Buffer::new(width, height);
4818
4819 let mut new = Buffer::new(width, height);
4821 let mut expected_changes = std::collections::HashSet::new();
4822
4823 for i in 0..num_content_cells {
4824 let x = (i * 13 + 7) as u16 % width;
4825 let y = (i * 17 + 3) as u16 % height;
4826 new.set_raw(x, y, Cell::from_char('#'));
4827 expected_changes.insert((x, y));
4828 }
4829
4830 let diff = BufferDiff::compute(&old, &new);
4831
4832 for (x, y) in expected_changes {
4835 let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
4836 prop_assert!(
4837 in_diff,
4838 "Content cell at ({}, {}) missing from diff - would ghost",
4839 x,
4840 y
4841 );
4842 }
4843
4844 for (x, y) in diff.iter() {
4846 let old_cell = old.get_unchecked(x, y);
4847 let new_cell = new.get_unchecked(x, y);
4848 prop_assert!(
4849 !old_cell.bits_eq(new_cell),
4850 "Diff includes unchanged cell at ({}, {})",
4851 x,
4852 y
4853 );
4854 }
4855 });
4856 }
4857
4858 #[test]
4866 fn diff_changes_are_monotonic() {
4867 proptest::proptest!(|(
4868 width in 10u16..80,
4869 height in 5u16..30,
4870 num_changes in 1usize..200,
4871 )| {
4872 let old = Buffer::new(width, height);
4873 let mut new = old.clone();
4874
4875 for i in 0..num_changes {
4877 let x = (i * 37 + 11) as u16 % width;
4878 let y = (i * 53 + 7) as u16 % height;
4879 new.set_raw(x, y, Cell::from_char('M'));
4880 }
4881
4882 let diff = BufferDiff::compute(&old, &new);
4883 let changes: Vec<_> = diff.iter().collect();
4884
4885 for window in changes.windows(2) {
4887 let (x1, y1) = window[0];
4888 let (x2, y2) = window[1];
4889
4890 let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
4891 prop_assert!(
4892 is_monotonic,
4893 "Changes not monotonic: ({}, {}) should come before ({}, {})",
4894 x1,
4895 y1,
4896 x2,
4897 y2
4898 );
4899 }
4900 });
4901 }
4902}
4903
4904#[cfg(test)]
4905mod span_edge_cases {
4906 use super::*;
4907 use crate::cell::Cell;
4908 use proptest::prelude::*;
4909
4910 #[test]
4911 fn test_span_diff_u16_max_width() {
4912 let width = 65000;
4914 let height = 1;
4915 let old = Buffer::new(width, height);
4916 let mut new = Buffer::new(width, height);
4917
4918 new.clear_dirty();
4920
4921 new.set_raw(0, 0, Cell::from_char('A'));
4923 new.set_raw(32500, 0, Cell::from_char('B'));
4924 new.set_raw(64999, 0, Cell::from_char('C'));
4925
4926 let full = BufferDiff::compute(&old, &new);
4927 let dirty = BufferDiff::compute_dirty(&old, &new);
4928
4929 assert_eq!(full.changes(), dirty.changes());
4930 assert_eq!(full.len(), 3);
4931
4932 let changes = full.changes();
4934 assert!(changes.contains(&(0, 0)));
4935 assert!(changes.contains(&(32500, 0)));
4936 assert!(changes.contains(&(64999, 0)));
4937 }
4938
4939 #[test]
4940 fn test_span_full_row_dirty_overflow() {
4941 let width = 1000;
4942 let height = 1;
4943 let old = Buffer::new(width, height);
4944 let mut new = Buffer::new(width, height);
4945 new.clear_dirty(); for i in 0..70 {
4950 let x = (i * 10) as u16;
4951 new.set_raw(x, 0, Cell::from_char('X'));
4952 }
4953
4954 let stats = new.dirty_span_stats();
4956 assert!(
4957 stats.rows_full_dirty > 0,
4958 "Should have overflowed to full row"
4959 );
4960 assert_eq!(
4961 stats.rows_with_spans, 0,
4962 "Should have cleared spans on overflow"
4963 );
4964
4965 let full = BufferDiff::compute(&old, &new);
4966 let dirty = BufferDiff::compute_dirty(&old, &new);
4967
4968 assert_eq!(full.changes(), dirty.changes());
4969 assert_eq!(full.len(), 70);
4970 }
4971
4972 #[test]
4973 fn test_span_diff_empty_rows() {
4974 let width = 100;
4975 let height = 10;
4976 let old = Buffer::new(width, height);
4977 let mut new = Buffer::new(width, height);
4978 new.clear_dirty(); let dirty = BufferDiff::compute_dirty(&old, &new);
4982 assert!(dirty.is_empty());
4983 }
4984
4985 proptest! {
4986 #[test]
4987 fn property_span_diff_equivalence_large(
4988 width in 1000u16..5000,
4989 height in 10u16..50,
4990 changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
4991 ) {
4992 let w = width.min(2000);
4994 let h = height.min(50);
4995
4996 let old = Buffer::new(w, h);
4997 let mut new = Buffer::new(w, h);
4998 new.clear_dirty();
4999
5000 for (raw_x, raw_y) in changes {
5002 let x = raw_x % w;
5003 let y = raw_y % h;
5004 new.set_raw(x, y, Cell::from_char('Z'));
5005 }
5006
5007 let full = BufferDiff::compute(&old, &new);
5008 let dirty = BufferDiff::compute_dirty(&old, &new);
5009
5010 prop_assert_eq!(
5011 full.changes(),
5012 dirty.changes(),
5013 "Large buffer mismatch: w={}, h={}, {}",
5014 w,
5015 h,
5016 super::span_diagnostics(&new)
5017 );
5018 }
5019 }
5020
5021 #[test]
5026 fn certified_skip_produces_empty_diff() {
5027 let old = Buffer::new(10, 5);
5028 let new = Buffer::new(10, 5);
5029 let mut diff = BufferDiff::new();
5030
5031 diff.compute_certified_into(&old, &new, DiffSkipHint::SkipDiff);
5032 assert!(diff.is_empty(), "SkipDiff should produce zero changes");
5033 }
5034
5035 #[test]
5036 fn certified_full_diff_matches_standard() {
5037 let old = Buffer::new(10, 5);
5038 let mut new = Buffer::new(10, 5);
5039 new.set(3, 2, Cell::from_char('X'));
5040
5041 let mut standard = BufferDiff::new();
5042 standard.compute_dirty_into(&old, &new);
5043
5044 let mut certified = BufferDiff::new();
5045 certified.compute_certified_into(&old, &new, DiffSkipHint::FullDiff);
5046
5047 assert_eq!(
5048 standard.changes(),
5049 certified.changes(),
5050 "FullDiff hint should produce identical results to standard dirty diff"
5051 );
5052 }
5053
5054 #[test]
5055 fn certified_narrow_to_rows_only_diffs_specified_rows() {
5056 let old = Buffer::new(10, 5);
5057 let mut new = Buffer::new(10, 5);
5058 new.set(2, 1, Cell::from_char('A'));
5060 new.set(5, 3, Cell::from_char('B'));
5061 new.set(7, 4, Cell::from_char('C'));
5063
5064 let mut diff = BufferDiff::new();
5065 diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![1, 3]));
5066
5067 let changes = diff.changes();
5069 assert!(
5070 changes.iter().any(|&(x, y)| x == 2 && y == 1),
5071 "should find change at (2, 1)"
5072 );
5073 assert!(
5074 changes.iter().any(|&(x, y)| x == 5 && y == 3),
5075 "should find change at (5, 3)"
5076 );
5077 assert!(
5078 !changes.iter().any(|&(_, y)| y == 4),
5079 "should NOT find changes in row 4 (not in hint)"
5080 );
5081 }
5082
5083 #[test]
5084 fn certified_narrow_skips_clean_rows() {
5085 let old = Buffer::new(10, 5);
5086 let new = Buffer::new(10, 5);
5087
5088 let mut diff = BufferDiff::new();
5089 diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![0, 2, 4]));
5090
5091 assert!(
5092 diff.is_empty(),
5093 "narrowing to clean rows should produce zero changes"
5094 );
5095 }
5096
5097 #[test]
5098 fn certified_narrow_out_of_bounds_rows_ignored() {
5099 let old = Buffer::new(10, 5);
5100 let mut new = Buffer::new(10, 5);
5101 new.set(0, 0, Cell::from_char('X'));
5102
5103 let mut diff = BufferDiff::new();
5104 diff.compute_certified_into(
5105 &old,
5106 &new,
5107 DiffSkipHint::NarrowToRows(vec![0, 100, 200]), );
5109
5110 assert_eq!(diff.len(), 1, "should find the one change in row 0");
5111 assert_eq!(diff.changes()[0], (0, 0));
5112 }
5113
5114 #[test]
5115 fn diff_skip_hint_labels() {
5116 assert_eq!(DiffSkipHint::FullDiff.label(), "full-diff");
5117 assert_eq!(DiffSkipHint::SkipDiff.label(), "skip-diff");
5118 assert_eq!(DiffSkipHint::NarrowToRows(vec![]).label(), "narrow-to-rows");
5119 }
5120
5121 #[test]
5122 fn diff_skip_hint_skips_work() {
5123 assert!(!DiffSkipHint::FullDiff.skips_work());
5124 assert!(DiffSkipHint::SkipDiff.skips_work());
5125 assert!(DiffSkipHint::NarrowToRows(vec![1]).skips_work());
5126 }
5127}