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 {
1385 let mut diff = Self::new();
1386 diff.compute_dirty_into(old, new);
1387 diff
1388 }
1389
1390 pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
1396 compute_dirty_changes(
1397 old,
1398 new,
1399 &mut self.changes,
1400 &mut self.tile_builder,
1401 &self.tile_config,
1402 &mut self.last_tile_stats,
1403 );
1404 }
1405
1406 pub fn compute_certified_into(&mut self, old: &Buffer, new: &Buffer, hint: DiffSkipHint) {
1434 match hint {
1435 DiffSkipHint::FullDiff => {
1436 self.compute_dirty_into(old, new);
1438 }
1439 DiffSkipHint::SkipDiff => {
1440 self.changes.clear();
1442 self.last_tile_stats = None;
1443 #[cfg(feature = "tracing")]
1444 tracing::debug!(
1445 event = "diff_skip_certified",
1446 hint = "skip_diff",
1447 changes = 0,
1448 );
1449 }
1450 DiffSkipHint::NarrowToRows(ref rows) => {
1451 self.changes.clear();
1453 self.last_tile_stats = None;
1454
1455 let w = old.width();
1456 let h = old.height();
1457 assert_eq!(w, new.width(), "diff dimension mismatch");
1461 assert_eq!(h, new.height(), "diff dimension mismatch");
1462
1463 let old_cells = old.cells();
1464 let new_cells = new.cells();
1465 let stride = w as usize;
1466
1467 let mut sorted_rows: Vec<u16> = rows.clone();
1473 sorted_rows.sort_unstable();
1474 sorted_rows.dedup();
1475
1476 for &row in &sorted_rows {
1477 if row >= h {
1478 continue;
1479 }
1480 let start = row as usize * stride;
1481 let end = start + stride;
1482 if end > old_cells.len() || end > new_cells.len() {
1483 continue;
1484 }
1485 let old_row = &old_cells[start..end];
1486 let new_row = &new_cells[start..end];
1487 if old_row == new_row {
1488 continue;
1489 }
1490 for (x, (o, n)) in old_row.iter().zip(new_row.iter()).enumerate() {
1491 if !o.bits_eq(n) {
1492 self.changes.push((x as u16, row));
1493 }
1494 }
1495 }
1496
1497 #[cfg(feature = "tracing")]
1498 tracing::debug!(
1499 event = "diff_narrow_certified",
1500 hint = "narrow_to_rows",
1501 rows_checked = sorted_rows.len(),
1502 changes = self.changes.len(),
1503 );
1504 }
1505 }
1506 }
1507
1508 pub fn fill_full(&mut self, width: u16, height: u16) {
1510 self.changes.clear();
1511 let total = width as usize * height as usize;
1512 if self.changes.capacity() < total {
1513 self.changes.reserve(total - self.changes.len());
1514 }
1515 for y in 0..height {
1516 for x in 0..width {
1517 self.changes.push((x, y));
1518 }
1519 }
1520 self.last_tile_stats = None;
1523 }
1524
1525 #[inline]
1527 pub fn len(&self) -> usize {
1528 self.changes.len()
1529 }
1530
1531 #[inline]
1533 pub fn is_empty(&self) -> bool {
1534 self.changes.is_empty()
1535 }
1536
1537 #[inline]
1539 pub fn changes(&self) -> &[(u16, u16)] {
1540 &self.changes
1541 }
1542
1543 #[inline]
1545 pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
1546 self.last_tile_stats
1547 }
1548
1549 #[inline]
1551 pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
1552 &mut self.tile_config
1553 }
1554
1555 pub fn runs(&self) -> Vec<ChangeRun> {
1560 #[cfg(feature = "tracing")]
1561 let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
1562 #[cfg(feature = "tracing")]
1563 let _guard = _span.enter();
1564
1565 if self.changes.is_empty() {
1566 return Vec::new();
1567 }
1568
1569 let sorted = &self.changes;
1572 let len = sorted.len();
1573
1574 let mut runs = Vec::with_capacity(len);
1577
1578 let mut i = 0;
1579
1580 while i < len {
1581 let (x0, y) = sorted[i];
1582 let mut x1 = x0;
1583 i += 1;
1584
1585 while i < len {
1587 let (x, yy) = sorted[i];
1588 if yy != y || x != x1.saturating_add(1) {
1589 break;
1590 }
1591 x1 = x;
1592 i += 1;
1593 }
1594
1595 runs.push(ChangeRun::new(y, x0, x1));
1596 }
1597
1598 #[cfg(feature = "tracing")]
1599 tracing::trace!(run_count = runs.len(), "runs coalesced");
1600
1601 runs
1602 }
1603
1604 pub fn runs_into(&self, out: &mut Vec<ChangeRun>) {
1607 out.clear();
1608
1609 #[cfg(feature = "tracing")]
1610 let _span = tracing::debug_span!("diff_runs_into", changes = self.changes.len());
1611 #[cfg(feature = "tracing")]
1612 let _guard = _span.enter();
1613
1614 if self.changes.is_empty() {
1615 return;
1616 }
1617
1618 let sorted = &self.changes;
1619 let len = sorted.len();
1620 let mut i = 0;
1621
1622 while i < len {
1623 let (x0, y) = sorted[i];
1624 let mut x1 = x0;
1625 i += 1;
1626
1627 while i < len {
1628 let (x, yy) = sorted[i];
1629 if yy != y || x != x1.saturating_add(1) {
1630 break;
1631 }
1632 x1 = x;
1633 i += 1;
1634 }
1635
1636 out.push(ChangeRun::new(y, x0, x1));
1637 }
1638
1639 #[cfg(feature = "tracing")]
1640 tracing::trace!(run_count = out.len(), "runs coalesced (reuse)");
1641 }
1642
1643 #[inline]
1645 pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
1646 self.changes.iter().copied()
1647 }
1648
1649 pub fn clear(&mut self) {
1651 self.changes.clear();
1652 self.last_tile_stats = None;
1653 }
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658 fn is_coverage_run() -> bool {
1659 if let Ok(value) = std::env::var("FTUI_COVERAGE") {
1660 let value = value.to_ascii_lowercase();
1661 if matches!(value.as_str(), "1" | "true" | "yes") {
1662 return true;
1663 }
1664 if matches!(value.as_str(), "0" | "false" | "no") {
1665 return false;
1666 }
1667 }
1668 std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
1669 }
1670 use super::*;
1671 use crate::cell::{Cell, PackedRgba};
1672
1673 #[test]
1674 fn scan_row_changes_range_reports_late_change_after_equal_quad() {
1675 let old = vec![Cell::default(); 8];
1676 let mut new = old.clone();
1677 let mut changes = Vec::new();
1678
1679 new[7] = Cell::from_char('X');
1680 scan_row_changes_range(&old, &new, 4, 10, &mut changes);
1681
1682 assert_eq!(changes, vec![(17, 4)]);
1683 }
1684
1685 #[test]
1686 fn empty_diff_when_buffers_identical() {
1687 let buf1 = Buffer::new(10, 10);
1688 let buf2 = Buffer::new(10, 10);
1689 let diff = BufferDiff::compute(&buf1, &buf2);
1690
1691 assert!(diff.is_empty());
1692 assert_eq!(diff.len(), 0);
1693 }
1694
1695 #[test]
1696 fn full_diff_marks_all_cells() {
1697 let diff = BufferDiff::full(3, 2);
1698 assert_eq!(diff.len(), 6);
1699 assert_eq!(diff.changes()[0], (0, 0));
1700 assert_eq!(diff.changes()[5], (2, 1));
1701 }
1702
1703 #[test]
1704 fn single_cell_change_detected() {
1705 let old = Buffer::new(10, 10);
1706 let mut new = Buffer::new(10, 10);
1707
1708 new.set_raw(5, 5, Cell::from_char('X'));
1709 let diff = BufferDiff::compute(&old, &new);
1710
1711 assert_eq!(diff.len(), 1);
1712 assert_eq!(diff.changes(), &[(5, 5)]);
1713 }
1714
1715 #[test]
1716 fn dirty_row_false_positive_skipped() {
1717 let old = Buffer::new(8, 2);
1718 let mut new = old.clone();
1719
1720 new.clear_dirty();
1722 new.set_raw(2, 0, Cell::default());
1724
1725 let diff = BufferDiff::compute_dirty(&old, &new);
1726 assert!(
1727 diff.is_empty(),
1728 "dirty row with no changes should be skipped"
1729 );
1730 }
1731
1732 #[test]
1733 fn multiple_scattered_changes_detected() {
1734 let old = Buffer::new(10, 10);
1735 let mut new = Buffer::new(10, 10);
1736
1737 new.set_raw(0, 0, Cell::from_char('A'));
1738 new.set_raw(9, 9, Cell::from_char('B'));
1739 new.set_raw(5, 3, Cell::from_char('C'));
1740
1741 let diff = BufferDiff::compute(&old, &new);
1742
1743 assert_eq!(diff.len(), 3);
1744 let changes = diff.changes();
1746 assert!(changes.contains(&(0, 0)));
1747 assert!(changes.contains(&(9, 9)));
1748 assert!(changes.contains(&(5, 3)));
1749 }
1750
1751 #[test]
1752 fn runs_coalesces_adjacent_cells() {
1753 let old = Buffer::new(10, 10);
1754 let mut new = Buffer::new(10, 10);
1755
1756 new.set_raw(3, 5, Cell::from_char('A'));
1758 new.set_raw(4, 5, Cell::from_char('B'));
1759 new.set_raw(5, 5, Cell::from_char('C'));
1760
1761 let diff = BufferDiff::compute(&old, &new);
1762 let runs = diff.runs();
1763
1764 assert_eq!(runs.len(), 1);
1765 assert_eq!(runs[0].y, 5);
1766 assert_eq!(runs[0].x0, 3);
1767 assert_eq!(runs[0].x1, 5);
1768 assert_eq!(runs[0].len(), 3);
1769 }
1770
1771 #[test]
1772 fn runs_handles_gaps_correctly() {
1773 let old = Buffer::new(10, 10);
1774 let mut new = Buffer::new(10, 10);
1775
1776 new.set_raw(0, 0, Cell::from_char('A'));
1778 new.set_raw(1, 0, Cell::from_char('B'));
1779 new.set_raw(3, 0, Cell::from_char('C'));
1781 new.set_raw(4, 0, Cell::from_char('D'));
1782
1783 let diff = BufferDiff::compute(&old, &new);
1784 let runs = diff.runs();
1785
1786 assert_eq!(runs.len(), 2);
1787
1788 assert_eq!(runs[0].y, 0);
1789 assert_eq!(runs[0].x0, 0);
1790 assert_eq!(runs[0].x1, 1);
1791
1792 assert_eq!(runs[1].y, 0);
1793 assert_eq!(runs[1].x0, 3);
1794 assert_eq!(runs[1].x1, 4);
1795 }
1796
1797 #[test]
1798 fn runs_handles_max_column_without_overflow() {
1799 let mut diff = BufferDiff::new();
1800 diff.changes = vec![(u16::MAX, 0)];
1801
1802 let runs = diff.runs();
1803
1804 assert_eq!(runs.len(), 1);
1805 assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1806 }
1807
1808 #[test]
1809 fn runs_handles_multiple_rows() {
1810 let old = Buffer::new(10, 10);
1811 let mut new = Buffer::new(10, 10);
1812
1813 new.set_raw(0, 0, Cell::from_char('A'));
1815 new.set_raw(1, 0, Cell::from_char('B'));
1816 new.set_raw(5, 2, Cell::from_char('C'));
1817 new.set_raw(0, 5, Cell::from_char('D'));
1818
1819 let diff = BufferDiff::compute(&old, &new);
1820 let runs = diff.runs();
1821
1822 assert_eq!(runs.len(), 3);
1823
1824 assert_eq!(runs[0].y, 0);
1826 assert_eq!(runs[0].x0, 0);
1827 assert_eq!(runs[0].x1, 1);
1828
1829 assert_eq!(runs[1].y, 2);
1831 assert_eq!(runs[1].x0, 5);
1832 assert_eq!(runs[1].x1, 5);
1833
1834 assert_eq!(runs[2].y, 5);
1836 assert_eq!(runs[2].x0, 0);
1837 assert_eq!(runs[2].x1, 0);
1838 }
1839
1840 #[test]
1841 fn empty_runs_from_empty_diff() {
1842 let diff = BufferDiff::new();
1843 let runs = diff.runs();
1844 assert!(runs.is_empty());
1845 }
1846
1847 #[test]
1848 fn change_run_len() {
1849 let run = ChangeRun::new(0, 5, 10);
1850 assert_eq!(run.len(), 6);
1851
1852 let single = ChangeRun::new(0, 5, 5);
1853 assert_eq!(single.len(), 1);
1854 }
1855
1856 #[test]
1857 fn color_changes_detected() {
1858 let old = Buffer::new(10, 10);
1859 let mut new = Buffer::new(10, 10);
1860
1861 new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1863
1864 let diff = BufferDiff::compute(&old, &new);
1865 assert_eq!(diff.len(), 1);
1866 }
1867
1868 #[test]
1869 fn diff_iter() {
1870 let old = Buffer::new(5, 5);
1871 let mut new = Buffer::new(5, 5);
1872 new.set_raw(1, 1, Cell::from_char('X'));
1873 new.set_raw(2, 2, Cell::from_char('Y'));
1874
1875 let diff = BufferDiff::compute(&old, &new);
1876 let positions: Vec<_> = diff.iter().collect();
1877
1878 assert_eq!(positions.len(), 2);
1879 assert!(positions.contains(&(1, 1)));
1880 assert!(positions.contains(&(2, 2)));
1881 }
1882
1883 #[test]
1884 fn diff_clear() {
1885 let old = Buffer::new(5, 5);
1886 let mut new = Buffer::new(5, 5);
1887 new.set_raw(1, 1, Cell::from_char('X'));
1888
1889 let mut diff = BufferDiff::compute(&old, &new);
1890 assert_eq!(diff.len(), 1);
1891
1892 diff.clear();
1893 assert!(diff.is_empty());
1894 }
1895
1896 #[test]
1897 fn with_capacity() {
1898 let diff = BufferDiff::with_capacity(100);
1899 assert!(diff.is_empty());
1900 }
1901
1902 #[test]
1903 fn full_buffer_change() {
1904 let old = Buffer::new(5, 5);
1905 let mut new = Buffer::new(5, 5);
1906
1907 for y in 0..5 {
1909 for x in 0..5 {
1910 new.set_raw(x, y, Cell::from_char('#'));
1911 }
1912 }
1913
1914 let diff = BufferDiff::compute(&old, &new);
1915 assert_eq!(diff.len(), 25);
1916
1917 let runs = diff.runs();
1919 assert_eq!(runs.len(), 5);
1920
1921 for (i, run) in runs.iter().enumerate() {
1922 assert_eq!(run.y, i as u16);
1923 assert_eq!(run.x0, 0);
1924 assert_eq!(run.x1, 4);
1925 assert_eq!(run.len(), 5);
1926 }
1927 }
1928
1929 #[test]
1930 fn row_major_order_preserved() {
1931 let old = Buffer::new(3, 3);
1932 let mut new = Buffer::new(3, 3);
1933
1934 new.set_raw(2, 2, Cell::from_char('C'));
1936 new.set_raw(0, 0, Cell::from_char('A'));
1937 new.set_raw(1, 1, Cell::from_char('B'));
1938
1939 let diff = BufferDiff::compute(&old, &new);
1940
1941 let changes = diff.changes();
1943 assert_eq!(changes[0], (0, 0));
1944 assert_eq!(changes[1], (1, 1));
1945 assert_eq!(changes[2], (2, 2));
1946 }
1947
1948 #[test]
1949 fn blockwise_scan_preserves_sparse_row_changes() {
1950 let old = Buffer::new(64, 2);
1951 let mut new = old.clone();
1952
1953 new.set_raw(1, 0, Cell::from_char('A'));
1954 new.set_raw(33, 0, Cell::from_char('B'));
1955 new.set_raw(62, 1, Cell::from_char('C'));
1956
1957 let diff = BufferDiff::compute(&old, &new);
1958 assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1959 }
1960
1961 #[test]
1962 fn rows_with_no_changes_are_skipped() {
1963 let old = Buffer::new(4, 3);
1964 let mut new = old.clone();
1965
1966 new.set_raw(1, 1, Cell::from_char('X'));
1967 new.set_raw(3, 1, Cell::from_char('Y'));
1968
1969 let diff = BufferDiff::compute(&old, &new);
1970 assert_eq!(diff.len(), 2);
1971 assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1972 }
1973
1974 #[test]
1975 fn clear_retains_capacity_for_reuse() {
1976 let mut diff = BufferDiff::with_capacity(16);
1977 diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1978 let capacity = diff.changes.capacity();
1979
1980 diff.clear();
1981
1982 assert!(diff.is_empty());
1983 assert!(diff.changes.capacity() >= capacity);
1984 }
1985
1986 #[test]
1987 #[should_panic(expected = "buffer widths must match")]
1988 fn compute_panics_on_width_mismatch() {
1989 let old = Buffer::new(5, 5);
1990 let new = Buffer::new(4, 5);
1991 let _ = BufferDiff::compute(&old, &new);
1992 }
1993
1994 #[test]
1999 fn block_scan_alignment_exact_block() {
2000 let old = Buffer::new(4, 1);
2002 let mut new = Buffer::new(4, 1);
2003 new.set_raw(2, 0, Cell::from_char('X'));
2004
2005 let diff = BufferDiff::compute(&old, &new);
2006 assert_eq!(diff.len(), 1);
2007 assert_eq!(diff.changes(), &[(2, 0)]);
2008 }
2009
2010 #[test]
2011 fn block_scan_alignment_remainder() {
2012 let old = Buffer::new(7, 1);
2014 let mut new = Buffer::new(7, 1);
2015 new.set_raw(1, 0, Cell::from_char('A'));
2017 new.set_raw(5, 0, Cell::from_char('B'));
2019 new.set_raw(6, 0, Cell::from_char('C'));
2020
2021 let diff = BufferDiff::compute(&old, &new);
2022 assert_eq!(diff.len(), 3);
2023 assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
2024 }
2025
2026 #[test]
2027 fn block_scan_single_cell_row() {
2028 let old = Buffer::new(1, 1);
2030 let mut new = Buffer::new(1, 1);
2031 new.set_raw(0, 0, Cell::from_char('X'));
2032
2033 let diff = BufferDiff::compute(&old, &new);
2034 assert_eq!(diff.len(), 1);
2035 assert_eq!(diff.changes(), &[(0, 0)]);
2036 }
2037
2038 #[test]
2039 fn block_scan_two_cell_row() {
2040 let old = Buffer::new(2, 1);
2042 let mut new = Buffer::new(2, 1);
2043 new.set_raw(0, 0, Cell::from_char('A'));
2044 new.set_raw(1, 0, Cell::from_char('B'));
2045
2046 let diff = BufferDiff::compute(&old, &new);
2047 assert_eq!(diff.len(), 2);
2048 assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
2049 }
2050
2051 #[test]
2052 fn block_scan_three_cell_row() {
2053 let old = Buffer::new(3, 1);
2055 let mut new = Buffer::new(3, 1);
2056 new.set_raw(2, 0, Cell::from_char('X'));
2057
2058 let diff = BufferDiff::compute(&old, &new);
2059 assert_eq!(diff.len(), 1);
2060 assert_eq!(diff.changes(), &[(2, 0)]);
2061 }
2062
2063 #[test]
2064 fn block_scan_multiple_blocks_sparse() {
2065 let old = Buffer::new(80, 1);
2067 let mut new = Buffer::new(80, 1);
2068
2069 for block in (0..20).step_by(2) {
2071 let x = (block * 4 + 1) as u16;
2072 new.set_raw(x, 0, Cell::from_char('X'));
2073 }
2074
2075 let diff = BufferDiff::compute(&old, &new);
2076 assert_eq!(diff.len(), 10);
2077 assert_eq!(
2078 diff.changes(),
2079 &[
2080 (1, 0),
2081 (9, 0),
2082 (17, 0),
2083 (25, 0),
2084 (33, 0),
2085 (41, 0),
2086 (49, 0),
2087 (57, 0),
2088 (65, 0),
2089 (73, 0)
2090 ]
2091 );
2092 }
2093
2094 #[test]
2095 fn block_scan_full_block_unchanged_skip() {
2096 let old = Buffer::new(20, 1);
2098 let mut new = Buffer::new(20, 1);
2099
2100 new.set_raw(19, 0, Cell::from_char('Z'));
2102
2103 let diff = BufferDiff::compute(&old, &new);
2104 assert_eq!(diff.len(), 1);
2105 assert_eq!(diff.changes(), &[(19, 0)]);
2106 }
2107
2108 #[test]
2109 fn block_scan_wide_row_all_changed() {
2110 let old = Buffer::new(120, 1);
2112 let mut new = Buffer::new(120, 1);
2113 for x in 0..120 {
2114 new.set_raw(x, 0, Cell::from_char('#'));
2115 }
2116
2117 let diff = BufferDiff::compute(&old, &new);
2118 assert_eq!(diff.len(), 120);
2119 }
2120
2121 #[test]
2122 fn perf_block_scan_vs_scalar_baseline() {
2123 use std::time::Instant;
2126
2127 let width = 200u16;
2128 let height = 50u16;
2129 let old = Buffer::new(width, height);
2130 let mut new = Buffer::new(width, height);
2131
2132 for i in 0..1000 {
2134 let x = (i * 7 + 3) as u16 % width;
2135 let y = (i * 11 + 5) as u16 % height;
2136 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2137 new.set_raw(x, y, Cell::from_char(ch));
2138 }
2139
2140 let iterations = 1000u32;
2141 let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
2142 .ok()
2143 .and_then(|value| value.parse::<usize>().ok())
2144 .unwrap_or(50)
2145 .clamp(1, iterations as usize);
2146 let iters_per_sample = (iterations / samples as u32).max(1) as u64;
2147
2148 let mut times_us = Vec::with_capacity(samples);
2149 let mut last_checksum = 0u64;
2150
2151 for _ in 0..samples {
2152 let start = Instant::now();
2153 for _ in 0..iters_per_sample {
2154 let diff = BufferDiff::compute(&old, &new);
2155 assert!(!diff.is_empty());
2156 last_checksum = fnv1a_hash(diff.changes());
2157 }
2158 let elapsed = start.elapsed();
2159 let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
2160 times_us.push(per_iter);
2161 }
2162
2163 times_us.sort_unstable();
2164 let len = times_us.len();
2165 let p50 = times_us[len / 2];
2166 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2167 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
2168 let mean = times_us
2169 .iter()
2170 .copied()
2171 .map(|value| value as f64)
2172 .sum::<f64>()
2173 / len as f64;
2174 let variance = times_us
2175 .iter()
2176 .map(|value| {
2177 let delta = *value as f64 - mean;
2178 delta * delta
2179 })
2180 .sum::<f64>()
2181 / len as f64;
2182
2183 eprintln!(
2185 "{{\"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}\"}}",
2186 width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
2187 );
2188
2189 #[allow(unexpected_cfgs)]
2193 let p50_budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
2194 #[allow(unexpected_cfgs)]
2195 let p95_budget_us = if is_coverage_run() {
2196 10_000u64
2197 } else {
2198 2_500u64
2199 };
2200
2201 assert!(
2202 p50 <= p50_budget_us,
2203 "Diff too slow: p50={p50}µs (budget {p50_budget_us}µs) for {width}x{height}"
2204 );
2205 assert!(
2206 p95 <= p95_budget_us,
2207 "Diff tail too slow: p95={p95}µs (budget {p95_budget_us}µs) for {width}x{height}"
2208 );
2209 }
2210 #[test]
2215 fn unit_run_coalescing_invariants() {
2216 let old = Buffer::new(80, 24);
2219 let mut new = Buffer::new(80, 24);
2220
2221 for x in 0..=2 {
2223 new.set_raw(x, 0, Cell::from_char('A'));
2224 }
2225 for x in 10..=12 {
2226 new.set_raw(x, 0, Cell::from_char('B'));
2227 }
2228 for x in 40..=45 {
2230 new.set_raw(x, 5, Cell::from_char('C'));
2231 }
2232 new.set_raw(79, 23, Cell::from_char('Z'));
2234
2235 let diff = BufferDiff::compute(&old, &new);
2236 let runs = diff.runs();
2237
2238 for w in runs.windows(2) {
2240 assert!(
2241 (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
2242 "runs must be sorted: {:?} should precede {:?}",
2243 w[0],
2244 w[1]
2245 );
2246 }
2247
2248 let total_cells: usize = runs.iter().map(|r| r.len()).sum();
2250 assert_eq!(
2251 total_cells,
2252 diff.len(),
2253 "runs must cover all changes exactly"
2254 );
2255
2256 for w in runs.windows(2) {
2258 if w[0].y == w[1].y {
2259 assert!(
2260 w[1].x0 > w[0].x1.saturating_add(1),
2261 "adjacent runs on same row should be merged: {:?} and {:?}",
2262 w[0],
2263 w[1]
2264 );
2265 }
2266 }
2267
2268 assert_eq!(runs.len(), 4);
2270 assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
2271 assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
2272 assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
2273 assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
2274 }
2275
2276 fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
2282 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2283 for &(x, y) in data {
2284 for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
2285 hash ^= *byte as u64;
2286 hash = hash.wrapping_mul(0x0100_0000_01b3);
2287 }
2288 }
2289 hash
2290 }
2291
2292 fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
2295 let mut buf = Buffer::new(width, height);
2296 let mut rng = seed;
2297
2298 for x in 0..width {
2300 buf.set_raw(x, 0, Cell::from_char('='));
2301 }
2302
2303 for x in 0..width {
2305 buf.set_raw(x, height - 1, Cell::from_char('-'));
2306 }
2307
2308 let count = (width as u64 * height as u64 / 10).max(5);
2310 for _ in 0..count {
2311 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2312 let x = ((rng >> 16) as u16) % width;
2313 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2314 let y = ((rng >> 16) as u16) % height;
2315 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
2316 let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
2317 buf.set_raw(x, y, Cell::from_char(ch));
2318 }
2319
2320 buf
2321 }
2322
2323 #[test]
2324 fn golden_diff_80x24() {
2325 let old = Buffer::new(80, 24);
2326 let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
2327
2328 let diff = BufferDiff::compute(&old, &new);
2329 let checksum = fnv1a_hash(diff.changes());
2330
2331 let diff2 = BufferDiff::compute(&old, &new);
2333 assert_eq!(
2334 fnv1a_hash(diff2.changes()),
2335 checksum,
2336 "diff must be deterministic"
2337 );
2338
2339 assert!(
2341 diff.len() >= 160,
2342 "80x24 golden scene should have at least 160 changes (header+status), got {}",
2343 diff.len()
2344 );
2345
2346 let runs = diff.runs();
2347 assert_eq!(runs[0].y, 0, "first run should be header row");
2349 assert_eq!(runs[0].x0, 0);
2350 assert_eq!(runs[0].x1, 79);
2351 assert!(
2352 runs.last().unwrap().y == 23,
2353 "last row should contain status bar"
2354 );
2355 }
2356
2357 #[test]
2358 fn golden_diff_120x40() {
2359 let old = Buffer::new(120, 40);
2360 let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
2361
2362 let diff = BufferDiff::compute(&old, &new);
2363 let checksum = fnv1a_hash(diff.changes());
2364
2365 let diff2 = BufferDiff::compute(&old, &new);
2367 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
2368
2369 assert!(
2371 diff.len() >= 240,
2372 "120x40 golden scene should have >=240 changes, got {}",
2373 diff.len()
2374 );
2375
2376 let dirty = BufferDiff::compute_dirty(&old, &new);
2378 assert_eq!(
2379 fnv1a_hash(dirty.changes()),
2380 checksum,
2381 "dirty diff must produce identical changes"
2382 );
2383 }
2384
2385 #[test]
2386 fn golden_sparse_update() {
2387 let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
2389 let mut new = old.clone();
2390
2391 new.set_raw(10, 5, Cell::from_char('!'));
2393 new.set_raw(11, 5, Cell::from_char('@'));
2394 new.set_raw(40, 12, Cell::from_char('#'));
2395 new.set_raw(70, 20, Cell::from_char('$'));
2396 new.set_raw(0, 23, Cell::from_char('%'));
2397
2398 let diff = BufferDiff::compute(&old, &new);
2399 let checksum = fnv1a_hash(diff.changes());
2400
2401 let diff2 = BufferDiff::compute(&old, &new);
2403 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
2404
2405 assert!(
2407 diff.len() <= 5,
2408 "sparse update should have <=5 changes, got {}",
2409 diff.len()
2410 );
2411 assert!(
2412 diff.len() >= 3,
2413 "sparse update should have >=3 changes, got {}",
2414 diff.len()
2415 );
2416 }
2417
2418 #[test]
2423 fn e2e_random_scene_replay() {
2424 let width = 80u16;
2427 let height = 24u16;
2428 let base_seed: u64 = 0x5C3E_E3E1_A442u64;
2429
2430 let mut checksums = Vec::new();
2431
2432 for frame in 0..10u64 {
2433 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2434 let old = build_golden_scene(width, height, seed);
2435 let new = build_golden_scene(width, height, seed.wrapping_add(1));
2436
2437 let diff = BufferDiff::compute(&old, &new);
2438 let dirty_diff = BufferDiff::compute_dirty(&old, &new);
2439
2440 assert_eq!(
2442 diff.changes(),
2443 dirty_diff.changes(),
2444 "frame {frame}: dirty diff must match full diff"
2445 );
2446
2447 checksums.push(fnv1a_hash(diff.changes()));
2448 }
2449
2450 for frame in 0..10u64 {
2452 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2453 let old = build_golden_scene(width, height, seed);
2454 let new = build_golden_scene(width, height, seed.wrapping_add(1));
2455
2456 let diff = BufferDiff::compute(&old, &new);
2457 assert_eq!(
2458 fnv1a_hash(diff.changes()),
2459 checksums[frame as usize],
2460 "frame {frame}: checksum mismatch on replay"
2461 );
2462 }
2463 }
2464
2465 #[test]
2470 fn perf_diff_microbench() {
2471 use std::time::Instant;
2472
2473 let scenarios: &[(u16, u16, &str, u64)] = &[
2474 (80, 24, "full_frame", 0xBE4C_0001u64),
2475 (80, 24, "sparse_update", 0xBE4C_0002u64),
2476 (120, 40, "full_frame", 0xBE4C_0003u64),
2477 (120, 40, "sparse_update", 0xBE4C_0004u64),
2478 ];
2479
2480 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2481 .ok()
2482 .and_then(|value| value.parse::<u32>().ok())
2483 .unwrap_or(50u32);
2484
2485 for &(width, height, scene_type, seed) in scenarios {
2486 let old = Buffer::new(width, height);
2487 let new = match scene_type {
2488 "full_frame" => build_golden_scene(width, height, seed),
2489 "sparse_update" => {
2490 let mut buf = old.clone();
2491 buf.set_raw(10, 5, Cell::from_char('!'));
2492 buf.set_raw(40, 12, Cell::from_char('#'));
2493 buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
2494 buf
2495 }
2496 _ => unreachable!(),
2497 };
2498
2499 let mut times_us = Vec::with_capacity(iterations as usize);
2500 let mut last_changes = 0usize;
2501 let mut last_runs = 0usize;
2502 let mut last_checksum = 0u64;
2503
2504 for _ in 0..iterations {
2505 let start = Instant::now();
2506 let diff = BufferDiff::compute(&old, &new);
2507 let runs = diff.runs();
2508 let elapsed = start.elapsed();
2509
2510 last_changes = diff.len();
2511 last_runs = runs.len();
2512 last_checksum = fnv1a_hash(diff.changes());
2513 times_us.push(elapsed.as_micros() as u64);
2514 }
2515
2516 times_us.sort();
2517 let len = times_us.len();
2518 let p50 = times_us[len / 2];
2519 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2520 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
2521
2522 eprintln!(
2524 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
2525 seed,
2526 width,
2527 height,
2528 scene_type,
2529 last_changes,
2530 last_runs,
2531 p50,
2532 p95,
2533 p99,
2534 last_checksum
2535 );
2536
2537 let budget_us = match (width, height) {
2540 (80, 24) => 500, (120, 40) => 1000, _ => 2000,
2543 };
2544
2545 for _ in 0..3 {
2547 let diff = BufferDiff::compute(&old, &new);
2548 assert_eq!(
2549 fnv1a_hash(diff.changes()),
2550 last_checksum,
2551 "diff must be deterministic"
2552 );
2553 }
2554
2555 if p95 > budget_us {
2557 eprintln!(
2558 "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
2559 );
2560 }
2561 }
2562 }
2563
2564 #[test]
2569 fn perf_dirty_diff_large_screen_regression() {
2570 use std::time::Instant;
2571
2572 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
2573 .ok()
2574 .and_then(|value| value.parse::<u32>().ok())
2575 .unwrap_or(50u32);
2576
2577 let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
2578 .ok()
2579 .and_then(|value| value.parse::<f64>().ok())
2580 .unwrap_or(2.0);
2581
2582 let cases: &[(u16, u16, &str, f64)] = &[
2583 (200, 60, "sparse_5pct", 5.0),
2584 (240, 80, "sparse_5pct", 5.0),
2585 (200, 60, "single_row", 0.0),
2586 (240, 80, "single_row", 0.0),
2587 ];
2588
2589 for &(width, height, pattern, pct) in cases {
2590 let old = Buffer::new(width, height);
2591 let mut new = old.clone();
2592
2593 if pattern == "single_row" {
2594 for x in 0..width {
2595 new.set_raw(x, 0, Cell::from_char('X'));
2596 }
2597 } else {
2598 let total = width as usize * height as usize;
2599 let to_change = ((total as f64) * pct / 100.0) as usize;
2600 for i in 0..to_change {
2601 let x = (i * 7 + 3) as u16 % width;
2602 let y = (i * 11 + 5) as u16 % height;
2603 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2604 new.set_raw(x, y, Cell::from_char(ch));
2605 }
2606 }
2607
2608 let full = BufferDiff::compute(&old, &new);
2610 let dirty = BufferDiff::compute_dirty(&old, &new);
2611 let change_count = full.len();
2612 let dirty_rows = new.dirty_row_count();
2613 assert_eq!(
2614 full.changes(),
2615 dirty.changes(),
2616 "dirty diff must match full diff for {width}x{height} {pattern}"
2617 );
2618
2619 let mut full_times = Vec::with_capacity(iterations as usize);
2620 let mut dirty_times = Vec::with_capacity(iterations as usize);
2621
2622 for _ in 0..iterations {
2623 let start = Instant::now();
2624 let diff = BufferDiff::compute(&old, &new);
2625 std::hint::black_box(diff.len());
2626 full_times.push(start.elapsed().as_micros() as u64);
2627
2628 let start = Instant::now();
2629 let diff = BufferDiff::compute_dirty(&old, &new);
2630 std::hint::black_box(diff.len());
2631 dirty_times.push(start.elapsed().as_micros() as u64);
2632 }
2633
2634 full_times.sort();
2635 dirty_times.sort();
2636
2637 let len = full_times.len();
2638 let p50_idx = len / 2;
2639 let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2640
2641 let full_p50 = full_times[p50_idx];
2642 let full_p95 = full_times[p95_idx];
2643 let dirty_p50 = dirty_times[p50_idx];
2644 let dirty_p95 = dirty_times[p95_idx];
2645
2646 let denom = full_p50.max(1) as f64;
2647 let ratio = dirty_p50 as f64 / denom;
2648
2649 eprintln!(
2650 "{{\"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\":{}}}",
2651 width,
2652 height,
2653 pattern,
2654 iterations,
2655 change_count,
2656 dirty_rows,
2657 full_p50,
2658 full_p95,
2659 dirty_p50,
2660 dirty_p95,
2661 ratio,
2662 max_slowdown
2663 );
2664
2665 assert!(
2666 ratio <= max_slowdown,
2667 "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2668 );
2669 }
2670 }
2671
2672 #[test]
2673 fn tile_diff_matches_compute_for_sparse_tiles() {
2674 let width = 200;
2675 let height = 60;
2676 let old = Buffer::new(width, height);
2677 let mut new = old.clone();
2678
2679 new.clear_dirty();
2680 for x in 0..10u16 {
2681 new.set_raw(x, 0, Cell::from_char('X'));
2682 }
2683
2684 let full = BufferDiff::compute(&old, &new);
2685 let dirty = BufferDiff::compute_dirty(&old, &new);
2686
2687 assert_eq!(full.changes(), dirty.changes());
2688 let stats = dirty
2689 .last_tile_stats()
2690 .expect("tile stats should be recorded");
2691 assert!(
2692 stats.fallback.is_none(),
2693 "tile path should be used for sparse tiles"
2694 );
2695 }
2696
2697 fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2698 let mut buffer = Buffer::new(width, height);
2699 buffer.clear_dirty();
2700 for &(x, y, ch) in changes {
2701 buffer.set_raw(x, y, Cell::from_char(ch));
2702 }
2703 buffer
2704 }
2705
2706 fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2707 let mut diff = BufferDiff::new();
2708 *diff.tile_config_mut() = config;
2709 diff.compute_dirty_into(old, new);
2710 diff.last_tile_stats()
2711 .expect("tile stats should be recorded")
2712 }
2713
2714 #[test]
2715 fn tile_fallback_disabled_when_config_off() {
2716 let width = 64;
2717 let height = 32;
2718 let old = Buffer::new(width, height);
2719 let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2720
2721 let config = TileDiffConfig {
2722 enabled: false,
2723 min_cells_for_tiles: 0,
2724 dense_cell_ratio: 1.1,
2725 dense_tile_ratio: 1.1,
2726 max_tiles: usize::MAX,
2727 ..Default::default()
2728 };
2729
2730 let stats = tile_stats_for_config(&old, &new, config);
2731 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2732 }
2733
2734 #[test]
2735 fn tile_fallback_small_screen_when_below_threshold() {
2736 let width = 64;
2737 let height = 32;
2738 let old = Buffer::new(width, height);
2739 let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2740
2741 let config = TileDiffConfig {
2742 enabled: true,
2743 min_cells_for_tiles: width as usize * height as usize + 1,
2744 dense_cell_ratio: 1.1,
2745 dense_tile_ratio: 1.1,
2746 max_tiles: usize::MAX,
2747 ..Default::default()
2748 };
2749
2750 let stats = tile_stats_for_config(&old, &new, config);
2751 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2752 }
2753
2754 #[test]
2755 fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2756 let width = 64;
2757 let height = 64;
2758 let old = Buffer::new(width, height);
2759 let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2760
2761 let config = TileDiffConfig {
2762 enabled: true,
2763 tile_w: 8,
2764 tile_h: 8,
2765 skip_clean_rows: true,
2766 min_cells_for_tiles: 0,
2767 dense_cell_ratio: 1.1,
2768 dense_tile_ratio: 1.1,
2769 max_tiles: 4,
2770 };
2771
2772 let stats = tile_stats_for_config(&old, &new, config);
2773 assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2774 }
2775
2776 #[test]
2777 fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2778 let width = 64;
2779 let height = 32;
2780 let old = Buffer::new(width, height);
2781 let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2782
2783 let config = TileDiffConfig {
2784 enabled: true,
2785 tile_w: 8,
2786 tile_h: 8,
2787 skip_clean_rows: true,
2788 min_cells_for_tiles: 0,
2789 dense_cell_ratio: 1.1,
2790 dense_tile_ratio: 0.05,
2791 max_tiles: usize::MAX / 4,
2792 };
2793
2794 let stats = tile_stats_for_config(&old, &new, config);
2795 assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2796 }
2797
2798 #[test]
2799 fn tile_builder_skips_clean_rows_when_enabled() {
2800 let width = 200;
2801 let height = 60;
2802 let old = Buffer::new(width, height);
2803 let mut new = old.clone();
2804 new.clear_dirty();
2805 new.set_raw(3, 0, Cell::from_char('X'));
2806 new.set_raw(4, 10, Cell::from_char('Y'));
2807
2808 let base = TileDiffConfig {
2809 enabled: true,
2810 tile_w: 16,
2811 tile_h: 8,
2812 min_cells_for_tiles: 0,
2813 dense_cell_ratio: 1.1,
2814 dense_tile_ratio: 1.1,
2815 max_tiles: usize::MAX,
2816 ..Default::default()
2817 };
2818 let config_full = TileDiffConfig {
2819 skip_clean_rows: false,
2820 ..base.clone()
2821 };
2822 let config_skip = TileDiffConfig {
2823 skip_clean_rows: true,
2824 ..base
2825 };
2826
2827 let stats_full = tile_stats_for_config(&old, &new, config_full);
2828 let stats_skip = tile_stats_for_config(&old, &new, config_skip);
2829
2830 assert!(
2832 stats_full.sat_build_cells > 0,
2833 "full scan should process cells, got 0"
2834 );
2835 assert!(
2837 stats_skip.sat_build_cells < stats_full.sat_build_cells,
2838 "skip_clean_rows should process fewer cells than full scan: skip={} full={}",
2839 stats_skip.sat_build_cells,
2840 stats_full.sat_build_cells
2841 );
2842 assert!(
2845 stats_skip.sat_build_cells <= stats_full.sat_build_cells / 2,
2846 "skip_clean_rows should save at least 50%: skip={} full={}",
2847 stats_skip.sat_build_cells,
2848 stats_full.sat_build_cells
2849 );
2850 }
2851
2852 fn lcg_next(state: &mut u64) -> u64 {
2853 *state = state
2854 .wrapping_mul(6364136223846793005)
2855 .wrapping_add(1442695040888963407);
2856 *state
2857 }
2858
2859 fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2860 let width = buf.width().max(1) as u64;
2861 let height = buf.height().max(1) as u64;
2862 let mut state = seed;
2863 for i in 0..count {
2864 let v = lcg_next(&mut state);
2865 let x = (v % width) as u16;
2866 let y = ((v >> 32) % height) as u16;
2867 let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2868 buf.set_raw(x, y, Cell::from_char(ch));
2869 }
2870 }
2871
2872 fn tile_diag(stats: &TileDiffStats) -> String {
2873 let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2874 format!(
2875 "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2876 stats.dirty_tiles,
2877 stats.skipped_tiles,
2878 stats.dirty_cells,
2879 stats.dirty_tile_ratio,
2880 stats.dirty_cell_ratio,
2881 stats.scanned_tiles,
2882 stats.fallback
2883 )
2884 }
2885
2886 fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2887 let mut diff = BufferDiff::new();
2888 {
2889 let config = diff.tile_config_mut();
2890 config.enabled = true;
2891 config.tile_w = 8;
2892 config.tile_h = 8;
2893 config.min_cells_for_tiles = 0;
2894 config.dense_cell_ratio = 1.1;
2895 config.dense_tile_ratio = 1.1;
2896 config.max_tiles = usize::MAX / 4;
2897 }
2898 diff.compute_dirty_into(old, new);
2899 let stats = diff
2900 .last_tile_stats()
2901 .expect("tile stats should be recorded");
2902 (diff, stats)
2903 }
2904
2905 fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2906 let full = BufferDiff::compute(old, new);
2907 let (dirty, stats) = diff_with_forced_tiles(old, new);
2908 let diag = tile_diag(&stats);
2909 assert!(
2910 stats.fallback.is_none(),
2911 "tile diff fallback ({label}) {w}x{h}: {diag}",
2912 w = old.width(),
2913 h = old.height()
2914 );
2915 assert!(
2916 full.changes() == dirty.changes(),
2917 "tile diff mismatch ({label}) {w}x{h}: {diag}",
2918 w = old.width(),
2919 h = old.height()
2920 );
2921 }
2922
2923 #[test]
2924 fn tile_diff_equivalence_small_and_odd_sizes() {
2925 let cases: &[(u16, u16, usize)] = &[
2926 (1, 1, 1),
2927 (2, 1, 1),
2928 (1, 2, 1),
2929 (5, 3, 4),
2930 (7, 13, 12),
2931 (15, 9, 20),
2932 (31, 5, 12),
2933 ];
2934
2935 for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2936 let old = Buffer::new(width, height);
2937 let mut new = old.clone();
2938 new.clear_dirty();
2939 apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2940 assert_tile_diff_equivalence(&old, &new, "small_odd");
2941 }
2942 }
2943
2944 #[test]
2945 fn tile_diff_equivalence_large_sparse_random() {
2946 let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2947 for (idx, &(width, height)) in cases.iter().enumerate() {
2948 let old = Buffer::new(width, height);
2949 let mut new = old.clone();
2950 new.clear_dirty();
2951 let total = width as usize * height as usize;
2952 let changes = (total / 100).max(1);
2953 apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2954 assert_tile_diff_equivalence(&old, &new, "large_sparse");
2955 }
2956 }
2957
2958 #[test]
2959 fn tile_diff_equivalence_row_and_full_buffer() {
2960 let width = 200u16;
2961 let height = 60u16;
2962 let old = Buffer::new(width, height);
2963
2964 let mut row = old.clone();
2965 row.clear_dirty();
2966 for x in 0..width {
2967 row.set_raw(x, 0, Cell::from_char('R'));
2968 }
2969 assert_tile_diff_equivalence(&old, &row, "single_row");
2970
2971 let mut full = old.clone();
2972 full.clear_dirty();
2973 for y in 0..height {
2974 for x in 0..width {
2975 full.set_raw(x, y, Cell::from_char('F'));
2976 }
2977 }
2978 assert_tile_diff_equivalence(&old, &full, "full_buffer");
2979 }
2980
2981 #[test]
2988 fn full_zero_width_returns_empty() {
2989 let diff = BufferDiff::full(0, 10);
2990 assert!(diff.is_empty());
2991 }
2992
2993 #[test]
2994 fn full_zero_height_returns_empty() {
2995 let diff = BufferDiff::full(10, 0);
2996 assert!(diff.is_empty());
2997 }
2998
2999 #[test]
3000 fn full_zero_both_returns_empty() {
3001 let diff = BufferDiff::full(0, 0);
3002 assert!(diff.is_empty());
3003 }
3004
3005 #[test]
3006 fn full_single_cell_has_one_change() {
3007 let diff = BufferDiff::full(1, 1);
3008 assert_eq!(diff.len(), 1);
3009 assert_eq!(diff.changes(), &[(0, 0)]);
3010 }
3011
3012 #[test]
3013 fn full_row_major_order() {
3014 let diff = BufferDiff::full(2, 3);
3015 assert_eq!(
3016 diff.changes(),
3017 &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]
3018 );
3019 }
3020
3021 #[test]
3024 fn runs_into_matches_runs_output() {
3025 let old = Buffer::new(20, 5);
3026 let mut new = Buffer::new(20, 5);
3027 new.set_raw(0, 0, Cell::from_char('A'));
3028 new.set_raw(1, 0, Cell::from_char('B'));
3029 new.set_raw(5, 2, Cell::from_char('C'));
3030 new.set_raw(10, 4, Cell::from_char('D'));
3031 new.set_raw(11, 4, Cell::from_char('E'));
3032
3033 let diff = BufferDiff::compute(&old, &new);
3034 let runs = diff.runs();
3035 let mut runs_buf = Vec::new();
3036 diff.runs_into(&mut runs_buf);
3037
3038 assert_eq!(runs, runs_buf);
3039 }
3040
3041 #[test]
3042 fn runs_into_clears_previous_content() {
3043 let diff = BufferDiff::new();
3044 let mut out = vec![ChangeRun::new(0, 0, 0)];
3045 diff.runs_into(&mut out);
3046 assert!(out.is_empty());
3047 }
3048
3049 #[test]
3050 fn runs_into_preserves_capacity() {
3051 let old = Buffer::new(10, 2);
3052 let mut new = Buffer::new(10, 2);
3053 new.set_raw(0, 0, Cell::from_char('A'));
3054 let diff = BufferDiff::compute(&old, &new);
3055
3056 let mut out = Vec::with_capacity(64);
3057 diff.runs_into(&mut out);
3058 assert_eq!(out.len(), 1);
3059 assert!(out.capacity() >= 64);
3060 }
3061
3062 #[test]
3065 fn change_run_fields_accessible() {
3066 let run = ChangeRun::new(7, 3, 10);
3067 assert_eq!(run.y, 7);
3068 assert_eq!(run.x0, 3);
3069 assert_eq!(run.x1, 10);
3070 assert_eq!(run.len(), 8);
3071 assert!(!run.is_empty());
3072 }
3073
3074 #[test]
3075 fn change_run_single_cell_not_empty() {
3076 let run = ChangeRun::new(0, 5, 5);
3077 assert_eq!(run.len(), 1);
3078 assert!(!run.is_empty());
3079 }
3080
3081 #[test]
3082 fn change_run_debug_format() {
3083 let run = ChangeRun::new(1, 2, 3);
3084 let dbg = format!("{:?}", run);
3085 assert!(dbg.contains("ChangeRun"), "Debug output: {dbg}");
3086 }
3087
3088 #[test]
3089 fn change_run_clone_copy_eq() {
3090 let run = ChangeRun::new(5, 10, 20);
3091 let copied = run; assert_eq!(run, copied);
3093 let cloned: ChangeRun = run; assert_eq!(run, cloned);
3095 }
3096
3097 #[test]
3098 fn change_run_ne() {
3099 let a = ChangeRun::new(0, 0, 5);
3100 let b = ChangeRun::new(0, 0, 6);
3101 assert_ne!(a, b);
3102 }
3103
3104 #[test]
3107 fn tile_diff_config_default_values() {
3108 let config = TileDiffConfig::default();
3109 assert!(config.enabled);
3110 assert_eq!(config.tile_w, 16);
3111 assert_eq!(config.tile_h, 8);
3112 assert!(config.skip_clean_rows);
3113 assert_eq!(config.min_cells_for_tiles, 12_000);
3114 assert!((config.dense_cell_ratio - 0.25).abs() < f64::EPSILON);
3115 assert!((config.dense_tile_ratio - 0.60).abs() < f64::EPSILON);
3116 assert_eq!(config.max_tiles, 4096);
3117 }
3118
3119 #[test]
3120 fn tile_diff_config_builder_methods() {
3121 let config = TileDiffConfig::default()
3122 .with_enabled(false)
3123 .with_tile_size(32, 32)
3124 .with_min_cells_for_tiles(500)
3125 .with_skip_clean_rows(false)
3126 .with_dense_cell_ratio(0.5)
3127 .with_dense_tile_ratio(0.8)
3128 .with_max_tiles(100);
3129
3130 assert!(!config.enabled);
3131 assert_eq!(config.tile_w, 32);
3132 assert_eq!(config.tile_h, 32);
3133 assert!(!config.skip_clean_rows);
3134 assert_eq!(config.min_cells_for_tiles, 500);
3135 assert!((config.dense_cell_ratio - 0.5).abs() < f64::EPSILON);
3136 assert!((config.dense_tile_ratio - 0.8).abs() < f64::EPSILON);
3137 assert_eq!(config.max_tiles, 100);
3138 }
3139
3140 #[test]
3141 fn tile_diff_config_debug_clone() {
3142 let config = TileDiffConfig::default();
3143 let dbg = format!("{:?}", config);
3144 assert!(dbg.contains("TileDiffConfig"), "Debug: {dbg}");
3145 let cloned = config.clone();
3146 assert_eq!(cloned.tile_w, 16);
3147 }
3148
3149 #[test]
3152 fn tile_diff_fallback_as_str_all_variants() {
3153 assert_eq!(TileDiffFallback::Disabled.as_str(), "disabled");
3154 assert_eq!(TileDiffFallback::SmallScreen.as_str(), "small_screen");
3155 assert_eq!(TileDiffFallback::DirtyAll.as_str(), "dirty_all");
3156 assert_eq!(TileDiffFallback::DenseCells.as_str(), "dense_cells");
3157 assert_eq!(TileDiffFallback::DenseTiles.as_str(), "dense_tiles");
3158 assert_eq!(TileDiffFallback::TooManyTiles.as_str(), "too_many_tiles");
3159 assert_eq!(TileDiffFallback::Overflow.as_str(), "overflow");
3160 }
3161
3162 #[test]
3163 fn tile_diff_fallback_traits() {
3164 let a = TileDiffFallback::Disabled;
3165 let b = a; assert_eq!(a, b);
3167 let c: TileDiffFallback = a; assert_eq!(a, c);
3169 assert_ne!(a, TileDiffFallback::Overflow);
3170 let dbg = format!("{:?}", a);
3171 assert!(dbg.contains("Disabled"), "Debug: {dbg}");
3172 }
3173
3174 #[test]
3177 fn tile_builder_dirty_all_fallback() {
3178 let mut builder = TileDiffBuilder::new();
3179 let config = TileDiffConfig::default().with_min_cells_for_tiles(0);
3180 let dirty_rows = vec![true; 10];
3181 let dirty_bits = vec![1u8; 200]; let input = TileDiffInput {
3184 width: 20,
3185 height: 10,
3186 dirty_rows: &dirty_rows,
3187 dirty_bits: &dirty_bits,
3188 dirty_cells: 200,
3189 dirty_all: true,
3190 };
3191
3192 let result = builder.build(&config, input);
3193 assert!(matches!(
3194 result,
3195 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DirtyAll)
3196 ));
3197 }
3198
3199 #[test]
3200 fn tile_builder_dense_cells_fallback() {
3201 let mut builder = TileDiffBuilder::new();
3203 let config = TileDiffConfig::default()
3204 .with_min_cells_for_tiles(0)
3205 .with_dense_cell_ratio(0.10); let w = 20u16;
3208 let h = 10u16;
3209 let total = (w as usize) * (h as usize);
3210 let dirty_count = total / 10; let dirty_rows = vec![true; h as usize];
3213 let dirty_bits = vec![0u8; total];
3214
3215 let input = TileDiffInput {
3216 width: w,
3217 height: h,
3218 dirty_rows: &dirty_rows,
3219 dirty_bits: &dirty_bits,
3220 dirty_cells: dirty_count,
3221 dirty_all: false,
3222 };
3223
3224 let result = builder.build(&config, input);
3225 assert!(matches!(
3226 result,
3227 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
3228 ));
3229 }
3230
3231 #[test]
3232 fn tile_builder_reuse_across_builds() {
3233 let mut builder = TileDiffBuilder::new();
3234 let config = TileDiffConfig::default()
3235 .with_min_cells_for_tiles(0)
3236 .with_dense_tile_ratio(1.1)
3237 .with_dense_cell_ratio(1.1)
3238 .with_max_tiles(usize::MAX / 4);
3239
3240 let dirty_rows_1 = vec![true; 10];
3242 let mut dirty_bits_1 = vec![0u8; 200];
3243 dirty_bits_1[0] = 1;
3244 let input_1 = TileDiffInput {
3245 width: 20,
3246 height: 10,
3247 dirty_rows: &dirty_rows_1,
3248 dirty_bits: &dirty_bits_1,
3249 dirty_cells: 1,
3250 dirty_all: false,
3251 };
3252 let result_1 = builder.build(&config, input_1);
3253 assert!(matches!(result_1, TileDiffBuild::UseTiles(_)));
3254
3255 let dirty_rows_2 = vec![true; 15];
3257 let mut dirty_bits_2 = vec![0u8; 450];
3258 dirty_bits_2[200] = 1;
3259 let input_2 = TileDiffInput {
3260 width: 30,
3261 height: 15,
3262 dirty_rows: &dirty_rows_2,
3263 dirty_bits: &dirty_bits_2,
3264 dirty_cells: 1,
3265 dirty_all: false,
3266 };
3267 let result_2 = builder.build(&config, input_2);
3268 assert!(matches!(result_2, TileDiffBuild::UseTiles(_)));
3269 }
3270
3271 #[test]
3272 fn tile_builder_default_matches_new() {
3273 let from_new = TileDiffBuilder::new();
3274 let from_default = TileDiffBuilder::default();
3275 assert_eq!(format!("{:?}", from_new), format!("{:?}", from_default));
3276 }
3277
3278 #[test]
3281 fn tile_params_total_tiles_and_cells() {
3282 let params = TileParams {
3283 width: 100,
3284 height: 50,
3285 tile_w: 16,
3286 tile_h: 8,
3287 tiles_x: 7, tiles_y: 7, };
3290 assert_eq!(params.total_tiles(), 49);
3291 assert_eq!(params.total_cells(), 5000);
3292 }
3293
3294 #[test]
3297 fn tile_diff_stats_from_builder() {
3298 let mut builder = TileDiffBuilder::new();
3299 let config = TileDiffConfig::default()
3300 .with_min_cells_for_tiles(0)
3301 .with_dense_tile_ratio(1.1)
3302 .with_dense_cell_ratio(1.1)
3303 .with_max_tiles(usize::MAX / 4);
3304
3305 let w = 32u16;
3306 let h = 16u16;
3307 let dirty_rows = vec![true; h as usize];
3308 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
3309 dirty_bits[0] = 1; let input = TileDiffInput {
3312 width: w,
3313 height: h,
3314 dirty_rows: &dirty_rows,
3315 dirty_bits: &dirty_bits,
3316 dirty_cells: 1,
3317 dirty_all: false,
3318 };
3319
3320 let result = builder.build(&config, input);
3321 match result {
3322 TileDiffBuild::UseTiles(plan) => {
3323 let stats = plan.stats;
3324 assert_eq!(stats.width, w);
3325 assert_eq!(stats.height, h);
3326 assert_eq!(stats.tile_w, 16);
3328 assert_eq!(stats.tile_h, 8);
3329 assert_eq!(stats.tiles_x, 2);
3331 assert_eq!(stats.tiles_y, 2);
3332 assert_eq!(stats.total_tiles, 4);
3333 assert_eq!(stats.dirty_cells, 1);
3334 assert_eq!(stats.dirty_tiles, 1);
3335 assert_eq!(stats.skipped_tiles, 3);
3336 assert!(stats.fallback.is_none());
3337 let copy = stats;
3339 assert_eq!(copy.width, stats.width);
3340 }
3341 _ => unreachable!("expected UseTiles"),
3342 }
3343 }
3344
3345 #[test]
3348 fn tile_size_clamped_to_min_8() {
3349 let mut builder = TileDiffBuilder::new();
3350 let config = TileDiffConfig {
3351 enabled: true,
3352 tile_w: 1, tile_h: 0, skip_clean_rows: false,
3355 min_cells_for_tiles: 0,
3356 dense_cell_ratio: 1.1,
3357 dense_tile_ratio: 1.1,
3358 max_tiles: usize::MAX / 4,
3359 };
3360
3361 let dirty_rows = vec![true; 16];
3362 let mut dirty_bits = vec![0u8; 256]; dirty_bits[0] = 1;
3364
3365 let input = TileDiffInput {
3366 width: 16,
3367 height: 16,
3368 dirty_rows: &dirty_rows,
3369 dirty_bits: &dirty_bits,
3370 dirty_cells: 1,
3371 dirty_all: false,
3372 };
3373
3374 let result = builder.build(&config, input);
3375 match result {
3376 TileDiffBuild::UseTiles(plan) => {
3377 assert_eq!(plan.stats.tile_w, 8);
3378 assert_eq!(plan.stats.tile_h, 8);
3379 }
3380 _ => unreachable!("expected UseTiles"),
3381 }
3382 }
3383
3384 #[test]
3385 fn tile_size_clamped_to_max_64() {
3386 let mut builder = TileDiffBuilder::new();
3387 let config = TileDiffConfig {
3388 enabled: true,
3389 tile_w: 255, tile_h: 100, skip_clean_rows: false,
3392 min_cells_for_tiles: 0,
3393 dense_cell_ratio: 1.1,
3394 dense_tile_ratio: 1.1,
3395 max_tiles: usize::MAX / 4,
3396 };
3397
3398 let dirty_rows = vec![true; 128];
3399 let mut dirty_bits = vec![0u8; 128 * 128];
3400 dirty_bits[0] = 1;
3401
3402 let input = TileDiffInput {
3403 width: 128,
3404 height: 128,
3405 dirty_rows: &dirty_rows,
3406 dirty_bits: &dirty_bits,
3407 dirty_cells: 1,
3408 dirty_all: false,
3409 };
3410
3411 let result = builder.build(&config, input);
3412 match result {
3413 TileDiffBuild::UseTiles(plan) => {
3414 assert_eq!(plan.stats.tile_w, 64);
3415 assert_eq!(plan.stats.tile_h, 64);
3416 }
3417 _ => unreachable!("expected UseTiles"),
3418 }
3419 }
3420
3421 #[test]
3424 fn buffer_diff_default_is_empty() {
3425 let diff: BufferDiff = Default::default();
3426 assert!(diff.is_empty());
3427 assert_eq!(diff.len(), 0);
3428 assert!(diff.last_tile_stats().is_none());
3429 }
3430
3431 #[test]
3432 fn buffer_diff_debug_format() {
3433 let diff = BufferDiff::new();
3434 let dbg = format!("{:?}", diff);
3435 assert!(dbg.contains("BufferDiff"), "Debug: {dbg}");
3436 }
3437
3438 #[test]
3439 fn buffer_diff_clone_preserves_changes() {
3440 let old = Buffer::new(5, 5);
3441 let mut new = Buffer::new(5, 5);
3442 new.set_raw(2, 3, Cell::from_char('X'));
3443 let diff = BufferDiff::compute(&old, &new);
3444 let cloned = diff.clone();
3445 assert_eq!(diff.changes(), cloned.changes());
3446 }
3447
3448 #[test]
3451 fn compute_into_replaces_previous_changes() {
3452 let mut diff = BufferDiff::new();
3453
3454 let old1 = Buffer::new(5, 5);
3455 let mut new1 = Buffer::new(5, 5);
3456 new1.set_raw(0, 0, Cell::from_char('A'));
3457 diff.compute_into(&old1, &new1);
3458 assert_eq!(diff.len(), 1);
3459
3460 let old2 = Buffer::new(3, 3);
3461 let mut new2 = Buffer::new(3, 3);
3462 new2.set_raw(1, 1, Cell::from_char('B'));
3463 new2.set_raw(2, 2, Cell::from_char('C'));
3464 diff.compute_into(&old2, &new2);
3465 assert_eq!(diff.len(), 2);
3466 assert_eq!(diff.changes(), &[(1, 1), (2, 2)]);
3467 }
3468
3469 #[test]
3470 fn compute_into_identical_clears() {
3471 let mut diff = BufferDiff::new();
3472 let old = Buffer::new(5, 5);
3473 let mut new = Buffer::new(5, 5);
3474 new.set_raw(0, 0, Cell::from_char('X'));
3475 diff.compute_into(&old, &new);
3476 assert_eq!(diff.len(), 1);
3477
3478 diff.compute_into(&old, &old);
3479 assert!(diff.is_empty());
3480 }
3481
3482 #[test]
3485 fn compute_dirty_into_no_dirty_rows() {
3486 let old = Buffer::new(10, 10);
3487 let mut new = old.clone();
3488 new.clear_dirty();
3489
3490 let mut diff = BufferDiff::new();
3491 diff.compute_dirty_into(&old, &new);
3492 assert!(diff.is_empty());
3493 }
3494
3495 #[test]
3496 fn compute_dirty_into_reuse() {
3497 let mut diff = BufferDiff::new();
3498
3499 let old = Buffer::new(10, 10);
3500 let mut new1 = old.clone();
3501 new1.clear_dirty();
3502 new1.set_raw(5, 5, Cell::from_char('X'));
3503 diff.compute_dirty_into(&old, &new1);
3504 assert_eq!(diff.len(), 1);
3505
3506 let mut new2 = old.clone();
3507 new2.clear_dirty();
3508 new2.set_raw(3, 3, Cell::from_char('Y'));
3509 new2.set_raw(7, 7, Cell::from_char('Z'));
3510 diff.compute_dirty_into(&old, &new2);
3511 assert_eq!(diff.len(), 2);
3512 assert_eq!(diff.changes(), &[(3, 3), (7, 7)]);
3513 }
3514
3515 #[test]
3518 fn last_tile_stats_none_after_compute_into() {
3519 let mut diff = BufferDiff::new();
3520 let old = Buffer::new(5, 5);
3521 let new = Buffer::new(5, 5);
3522 diff.compute_into(&old, &new);
3523 assert!(diff.last_tile_stats().is_none());
3524 }
3525
3526 #[test]
3527 fn last_tile_stats_some_after_compute_dirty_into() {
3528 let mut diff = BufferDiff::new();
3529 let old = Buffer::new(10, 10);
3530 let mut new = old.clone();
3531 new.set_raw(0, 0, Cell::from_char('A'));
3532 diff.compute_dirty_into(&old, &new);
3533 let stats = diff.last_tile_stats().expect("should have tile stats");
3535 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
3536 }
3537
3538 #[test]
3541 fn tile_config_mut_modifies_behavior() {
3542 let old = Buffer::new(200, 60);
3543 let mut new = old.clone();
3544 new.clear_dirty();
3545 new.set_raw(0, 0, Cell::from_char('X'));
3546
3547 let mut diff = BufferDiff::new();
3549 diff.compute_dirty_into(&old, &new);
3550 let stats = diff.last_tile_stats().expect("stats");
3551 assert!(
3552 stats.fallback.is_none(),
3553 "tiles should be active for 200x60"
3554 );
3555
3556 diff.tile_config_mut().enabled = false;
3558 diff.compute_dirty_into(&old, &new);
3559 let stats = diff.last_tile_stats().expect("stats");
3560 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
3561 }
3562
3563 #[test]
3566 fn row_scan_width_31_below_row_block_size() {
3567 let old = Buffer::new(31, 1);
3568 let mut new = Buffer::new(31, 1);
3569 new.set_raw(0, 0, Cell::from_char('A'));
3570 new.set_raw(15, 0, Cell::from_char('B'));
3571 new.set_raw(30, 0, Cell::from_char('C'));
3572
3573 let diff = BufferDiff::compute(&old, &new);
3574 assert_eq!(diff.len(), 3);
3575 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (30, 0)]);
3576 }
3577
3578 #[test]
3579 fn row_scan_width_32_exact_row_block_size() {
3580 let old = Buffer::new(32, 1);
3581 let mut new = Buffer::new(32, 1);
3582 new.set_raw(0, 0, Cell::from_char('A'));
3583 new.set_raw(15, 0, Cell::from_char('B'));
3584 new.set_raw(31, 0, Cell::from_char('C'));
3585
3586 let diff = BufferDiff::compute(&old, &new);
3587 assert_eq!(diff.len(), 3);
3588 assert_eq!(diff.changes(), &[(0, 0), (15, 0), (31, 0)]);
3589 }
3590
3591 #[test]
3592 fn row_scan_width_33_one_past_row_block_size() {
3593 let old = Buffer::new(33, 1);
3594 let mut new = Buffer::new(33, 1);
3595 new.set_raw(0, 0, Cell::from_char('A'));
3596 new.set_raw(31, 0, Cell::from_char('B'));
3597 new.set_raw(32, 0, Cell::from_char('C'));
3598
3599 let diff = BufferDiff::compute(&old, &new);
3600 assert_eq!(diff.len(), 3);
3601 assert_eq!(diff.changes(), &[(0, 0), (31, 0), (32, 0)]);
3602 }
3603
3604 #[test]
3607 fn dense_cells_exact_threshold_triggers_fallback() {
3608 let mut builder = TileDiffBuilder::new();
3609 let w = 20u16;
3610 let h = 20u16;
3611 let total = (w as usize) * (h as usize); let dirty_count = total / 4; let config = TileDiffConfig {
3615 enabled: true,
3616 tile_w: 8,
3617 tile_h: 8,
3618 skip_clean_rows: false,
3619 min_cells_for_tiles: 0,
3620 dense_cell_ratio: 0.25,
3621 dense_tile_ratio: 1.1,
3622 max_tiles: usize::MAX / 4,
3623 };
3624
3625 let dirty_rows = vec![true; h as usize];
3626 let dirty_bits = vec![1u8; total];
3627
3628 let input = TileDiffInput {
3629 width: w,
3630 height: h,
3631 dirty_rows: &dirty_rows,
3632 dirty_bits: &dirty_bits,
3633 dirty_cells: dirty_count,
3634 dirty_all: false,
3635 };
3636
3637 let result = builder.build(&config, input);
3638 assert!(matches!(
3639 result,
3640 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
3641 ));
3642 }
3643
3644 #[test]
3645 fn dense_cells_just_below_threshold_passes() {
3646 let mut builder = TileDiffBuilder::new();
3647 let w = 20u16;
3648 let h = 20u16;
3649 let total = (w as usize) * (h as usize); let dirty_count = total / 4 - 1; let config = TileDiffConfig {
3653 enabled: true,
3654 tile_w: 8,
3655 tile_h: 8,
3656 skip_clean_rows: false,
3657 min_cells_for_tiles: 0,
3658 dense_cell_ratio: 0.25,
3659 dense_tile_ratio: 1.1,
3660 max_tiles: usize::MAX / 4,
3661 };
3662
3663 let dirty_rows = vec![true; h as usize];
3664 let mut dirty_bits = vec![0u8; total];
3665 for bit in dirty_bits.iter_mut().take(dirty_count) {
3666 *bit = 1;
3667 }
3668
3669 let input = TileDiffInput {
3670 width: w,
3671 height: h,
3672 dirty_rows: &dirty_rows,
3673 dirty_bits: &dirty_bits,
3674 dirty_cells: dirty_count,
3675 dirty_all: false,
3676 };
3677
3678 let result = builder.build(&config, input);
3679 match &result {
3680 TileDiffBuild::Fallback(stats) => {
3681 assert_ne!(
3682 stats.fallback,
3683 Some(TileDiffFallback::DenseCells),
3684 "should not trigger DenseCells below 25%: {:?}",
3685 stats.fallback
3686 );
3687 }
3688 TileDiffBuild::UseTiles(_) => { }
3689 }
3690 }
3691
3692 #[test]
3695 fn switch_between_compute_and_dirty() {
3696 let mut diff = BufferDiff::new();
3697 let old = Buffer::new(10, 10);
3698 let mut new = Buffer::new(10, 10);
3699 new.set_raw(3, 3, Cell::from_char('X'));
3700
3701 diff.compute_into(&old, &new);
3702 assert_eq!(diff.len(), 1);
3703 assert!(diff.last_tile_stats().is_none());
3704
3705 diff.compute_dirty_into(&old, &new);
3706 assert_eq!(diff.len(), 1);
3707 assert!(diff.last_tile_stats().is_some());
3708
3709 diff.compute_into(&old, &new);
3710 assert_eq!(diff.len(), 1);
3711 assert!(diff.last_tile_stats().is_none());
3712 }
3713
3714 #[test]
3717 #[should_panic(expected = "buffer heights must match")]
3718 fn compute_panics_on_height_mismatch() {
3719 let old = Buffer::new(5, 5);
3720 let new = Buffer::new(5, 4);
3721 let _ = BufferDiff::compute(&old, &new);
3722 }
3723
3724 #[test]
3725 #[should_panic(expected = "buffer widths must match")]
3726 fn compute_dirty_panics_on_width_mismatch() {
3727 let old = Buffer::new(5, 5);
3728 let new = Buffer::new(4, 5);
3729 let _ = BufferDiff::compute_dirty(&old, &new);
3730 }
3731
3732 #[test]
3733 #[should_panic(expected = "buffer heights must match")]
3734 fn compute_dirty_panics_on_height_mismatch() {
3735 let old = Buffer::new(5, 5);
3736 let new = Buffer::new(5, 4);
3737 let _ = BufferDiff::compute_dirty(&old, &new);
3738 }
3739
3740 #[test]
3743 fn tile_diff_input_debug_copy() {
3744 let dirty_rows = vec![true; 2];
3745 let dirty_bits = vec![0u8; 4];
3746 let input = TileDiffInput {
3747 width: 2,
3748 height: 2,
3749 dirty_rows: &dirty_rows,
3750 dirty_bits: &dirty_bits,
3751 dirty_cells: 0,
3752 dirty_all: false,
3753 };
3754 let dbg = format!("{:?}", input);
3755 assert!(dbg.contains("TileDiffInput"), "Debug: {dbg}");
3756 let copy = input; assert_eq!(copy.width, input.width);
3758 }
3759
3760 #[test]
3761 fn tile_diff_build_debug() {
3762 let mut builder = TileDiffBuilder::new();
3763 let config = TileDiffConfig::default();
3764 let dirty_rows = vec![true; 1];
3765 let dirty_bits = vec![0u8; 4];
3766 let input = TileDiffInput {
3767 width: 4,
3768 height: 1,
3769 dirty_rows: &dirty_rows,
3770 dirty_bits: &dirty_bits,
3771 dirty_cells: 0,
3772 dirty_all: false,
3773 };
3774 let result = builder.build(&config, input);
3775 let dbg = format!("{:?}", result);
3776 assert!(dbg.contains("Fallback"), "Debug: {dbg}");
3777 }
3778
3779 #[test]
3782 fn full_diff_runs_one_per_row() {
3783 let diff = BufferDiff::full(10, 3);
3784 let runs = diff.runs();
3785 assert_eq!(runs.len(), 3);
3786 for (i, run) in runs.iter().enumerate() {
3787 assert_eq!(run.y, i as u16);
3788 assert_eq!(run.x0, 0);
3789 assert_eq!(run.x1, 9);
3790 assert_eq!(run.len(), 10);
3791 }
3792 }
3793
3794 #[test]
3797 fn dirty_diff_skips_false_positive_rows() {
3798 let old = Buffer::new(10, 5);
3799 let mut new = old.clone();
3800 new.clear_dirty();
3801
3802 for y in 0..5u16 {
3804 new.set_raw(0, y, Cell::default());
3805 }
3806 new.set_raw(5, 1, Cell::from_char('A'));
3808 new.set_raw(7, 3, Cell::from_char('B'));
3809
3810 let diff = BufferDiff::compute_dirty(&old, &new);
3811 assert_eq!(diff.len(), 2);
3812 assert!(diff.changes().contains(&(5, 1)));
3813 assert!(diff.changes().contains(&(7, 3)));
3814 }
3815
3816 #[test]
3819 fn bg_only_change_detected() {
3820 let old = Buffer::new(5, 1);
3821 let mut new = Buffer::new(5, 1);
3822 new.set_raw(2, 0, Cell::default().with_bg(PackedRgba::rgb(0, 0, 255)));
3823
3824 let diff = BufferDiff::compute(&old, &new);
3825 assert_eq!(diff.len(), 1);
3826 assert_eq!(diff.changes(), &[(2, 0)]);
3827 }
3828
3829 #[test]
3832 fn changes_at_buffer_corners() {
3833 let w = 100u16;
3834 let h = 50u16;
3835 let old = Buffer::new(w, h);
3836 let mut new = Buffer::new(w, h);
3837 new.set_raw(0, 0, Cell::from_char('A'));
3838 new.set_raw(w - 1, h - 1, Cell::from_char('Z'));
3839
3840 let diff = BufferDiff::compute(&old, &new);
3841 assert_eq!(diff.len(), 2);
3842 assert_eq!(diff.changes()[0], (0, 0));
3843 assert_eq!(diff.changes()[1], (w - 1, h - 1));
3844
3845 let runs = diff.runs();
3846 assert_eq!(runs.len(), 2);
3847 assert_eq!(runs[0], ChangeRun::new(0, 0, 0));
3848 assert_eq!(runs[1], ChangeRun::new(h - 1, w - 1, w - 1));
3849 }
3850
3851 #[test]
3854 fn tile_params_debug_copy() {
3855 let params = TileParams {
3856 width: 80,
3857 height: 24,
3858 tile_w: 16,
3859 tile_h: 8,
3860 tiles_x: 5,
3861 tiles_y: 3,
3862 };
3863 let dbg = format!("{:?}", params);
3864 assert!(dbg.contains("TileParams"), "Debug: {dbg}");
3865 let copy = params; assert_eq!(copy.width, params.width);
3867 let cloned: TileParams = params; assert_eq!(cloned.tiles_x, params.tiles_x);
3869 }
3870
3871 #[test]
3874 fn tile_diff_stats_debug_copy() {
3875 let mut builder = TileDiffBuilder::new();
3876 let config = TileDiffConfig::default();
3877 let dirty_rows = vec![true; 1];
3878 let dirty_bits = vec![0u8; 1];
3879 let input = TileDiffInput {
3880 width: 1,
3881 height: 1,
3882 dirty_rows: &dirty_rows,
3883 dirty_bits: &dirty_bits,
3884 dirty_cells: 0,
3885 dirty_all: false,
3886 };
3887 let result = builder.build(&config, input);
3888 match result {
3889 TileDiffBuild::Fallback(stats) => {
3890 let dbg = format!("{:?}", stats);
3891 assert!(dbg.contains("TileDiffStats"), "Debug: {dbg}");
3892 let copy = stats;
3893 assert_eq!(copy.width, stats.width);
3894 let cloned: TileDiffStats = stats; assert_eq!(cloned.height, stats.height);
3896 }
3897 _ => unreachable!("expected Fallback for 1x1"),
3898 }
3899 }
3900
3901 #[test]
3906 fn compute_dirty_with_targeted_spans() {
3907 let width = 100u16;
3910 let height = 10u16;
3911 let old = Buffer::new(width, height);
3912 let mut new = old.clone();
3913 new.clear_dirty();
3914
3915 new.set_raw(10, 3, Cell::from_char('A'));
3917 new.set_raw(11, 3, Cell::from_char('B'));
3918 new.set_raw(50, 7, Cell::from_char('C'));
3919
3920 let full = BufferDiff::compute(&old, &new);
3921 let dirty = BufferDiff::compute_dirty(&old, &new);
3922
3923 assert_eq!(full.changes(), dirty.changes());
3924 assert_eq!(dirty.len(), 3);
3925 assert!(dirty.changes().contains(&(10, 3)));
3926 assert!(dirty.changes().contains(&(11, 3)));
3927 assert!(dirty.changes().contains(&(50, 7)));
3928 }
3929
3930 #[test]
3931 fn compute_dirty_spans_on_multiple_rows() {
3932 let width = 80u16;
3934 let height = 5u16;
3935 let old = Buffer::new(width, height);
3936 let mut new = old.clone();
3937 new.clear_dirty();
3938
3939 new.set_raw(0, 0, Cell::from_char('A'));
3941 new.set_raw(1, 0, Cell::from_char('B'));
3942 new.set_raw(40, 2, Cell::from_char('C'));
3944 new.set_raw(79, 4, Cell::from_char('D'));
3946
3947 let full = BufferDiff::compute(&old, &new);
3948 let dirty = BufferDiff::compute_dirty(&old, &new);
3949
3950 assert_eq!(full.changes(), dirty.changes());
3951 assert_eq!(dirty.len(), 4);
3952 }
3953
3954 #[test]
3955 fn compute_dirty_span_with_false_positive_row() {
3956 let old = Buffer::new(20, 3);
3958 let mut new = old.clone();
3959 new.clear_dirty();
3960
3961 new.set_raw(5, 1, Cell::default());
3963 new.set_raw(10, 2, Cell::from_char('X'));
3965
3966 let dirty = BufferDiff::compute_dirty(&old, &new);
3967 assert_eq!(dirty.len(), 1);
3968 assert_eq!(dirty.changes(), &[(10, 2)]);
3969 }
3970
3971 #[test]
3972 fn compute_dirty_many_spans_per_row() {
3973 let width = 200u16;
3975 let height = 1u16;
3976 let old = Buffer::new(width, height);
3977 let mut new = old.clone();
3978 new.clear_dirty();
3979
3980 let positions: Vec<u16> = vec![5, 20, 35, 50, 65, 80, 95, 110, 125, 140];
3982 for &x in &positions {
3983 new.set_raw(x, 0, Cell::from_char('X'));
3984 }
3985
3986 let full = BufferDiff::compute(&old, &new);
3987 let dirty = BufferDiff::compute_dirty(&old, &new);
3988
3989 assert_eq!(full.changes(), dirty.changes());
3990 assert_eq!(dirty.len(), positions.len());
3991 }
3992
3993 #[test]
3998 fn tile_diff_with_dirty_spans_matches_full() {
3999 let width = 200u16;
4001 let height = 60u16;
4002 let old = Buffer::new(width, height);
4003 let mut new = old.clone();
4004 new.clear_dirty();
4005
4006 new.set_raw(5, 2, Cell::from_char('A'));
4008 new.set_raw(100, 30, Cell::from_char('B'));
4009 new.set_raw(195, 55, Cell::from_char('C'));
4010
4011 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
4012 let full = BufferDiff::compute(&old, &new);
4013
4014 assert!(stats.fallback.is_none(), "tile path should be used");
4015 assert_eq!(
4016 stats.sat_build_cells, 3,
4017 "span-aware tile build should only scan covered span cells"
4018 );
4019 assert_eq!(full.changes(), dirty_diff.changes());
4020 assert_eq!(dirty_diff.len(), 3);
4021 }
4022
4023 #[test]
4024 fn tile_diff_with_spans_straddling_tile_boundary() {
4025 let width = 200u16;
4027 let height = 60u16;
4028 let old = Buffer::new(width, height);
4029 let mut new = old.clone();
4030 new.clear_dirty();
4031
4032 new.set_raw(6, 0, Cell::from_char('A'));
4034 new.set_raw(7, 0, Cell::from_char('B'));
4035 new.set_raw(8, 0, Cell::from_char('C'));
4036 new.set_raw(9, 0, Cell::from_char('D'));
4037
4038 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
4039 let full = BufferDiff::compute(&old, &new);
4040
4041 assert!(stats.fallback.is_none());
4042 assert_eq!(full.changes(), dirty_diff.changes());
4043 assert_eq!(dirty_diff.len(), 4);
4044 }
4045
4046 #[test]
4047 fn tile_diff_single_dirty_tile_skips_others() {
4048 let width = 200u16;
4050 let height = 60u16;
4051 let old = Buffer::new(width, height);
4052 let mut new = old.clone();
4053 new.clear_dirty();
4054
4055 new.set_raw(3, 3, Cell::from_char('X'));
4057
4058 let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
4059
4060 assert!(stats.fallback.is_none());
4061 assert_eq!(dirty_diff.len(), 1);
4062 assert!(stats.skipped_tiles > 0, "should skip clean tiles");
4063 assert_eq!(stats.dirty_tiles, 1, "only one tile should be dirty");
4064 }
4065
4066 #[test]
4067 fn accumulate_tile_counts_range_single_cell_range() {
4068 let mut tile_counts = vec![0u32; 4];
4069 let dirty_bits = vec![0, 0, 1, 0, 0, 0, 0, 0];
4070 let mut scanned_cells = 0usize;
4071
4072 accumulate_tile_counts_range(
4073 &mut tile_counts,
4074 &dirty_bits,
4075 0,
4076 4,
4077 2,
4078 2..3,
4079 &mut scanned_cells,
4080 )
4081 .unwrap();
4082
4083 assert_eq!(scanned_cells, 1);
4084 assert_eq!(tile_counts, vec![0, 1, 0, 0]);
4085 }
4086
4087 #[test]
4088 fn accumulate_tile_counts_range_single_tile_multi_cell_range() {
4089 let mut tile_counts = vec![0u32; 4];
4090 let dirty_bits = vec![0, 1, 0, 1, 1, 0, 0, 0];
4091 let mut scanned_cells = 0usize;
4092
4093 accumulate_tile_counts_range(
4094 &mut tile_counts,
4095 &dirty_bits,
4096 0,
4097 4,
4098 2,
4099 4..6,
4100 &mut scanned_cells,
4101 )
4102 .unwrap();
4103
4104 assert_eq!(scanned_cells, 2);
4105 assert_eq!(tile_counts, vec![0, 0, 1, 0]);
4106 }
4107
4108 #[test]
4113 fn tile_diff_plan_fields_accessible() {
4114 let mut builder = TileDiffBuilder::new();
4115 let config = TileDiffConfig::default()
4116 .with_min_cells_for_tiles(0)
4117 .with_dense_tile_ratio(1.1)
4118 .with_dense_cell_ratio(1.1)
4119 .with_max_tiles(usize::MAX / 4);
4120
4121 let w = 32u16;
4122 let h = 16u16;
4123 let dirty_rows = vec![true; h as usize];
4124 let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
4125 dirty_bits[0] = 1; dirty_bits[8 + 8 * w as usize] = 1; let input = TileDiffInput {
4130 width: w,
4131 height: h,
4132 dirty_rows: &dirty_rows,
4133 dirty_bits: &dirty_bits,
4134 dirty_cells: 2,
4135 dirty_all: false,
4136 };
4137
4138 let result = builder.build(&config, input);
4139 match result {
4140 TileDiffBuild::UseTiles(plan) => {
4141 assert_eq!(plan.params.width, w);
4143 assert_eq!(plan.params.height, h);
4144 assert!(plan.params.tile_w >= 8);
4145 assert!(plan.params.tile_h >= 8);
4146 assert!(plan.params.tiles_x > 0);
4147 assert!(plan.params.tiles_y > 0);
4148
4149 assert_eq!(plan.dirty_tiles.len(), plan.params.total_tiles());
4151 let dirty_count: usize = plan.dirty_tiles.iter().filter(|&&d| d).count();
4152 assert_eq!(dirty_count, plan.stats.dirty_tiles);
4153
4154 assert_eq!(plan.tile_counts.len(), plan.params.total_tiles());
4156
4157 let expected_sat_len = (plan.params.tiles_x + 1) * (plan.params.tiles_y + 1);
4159 assert_eq!(plan.sat.len(), expected_sat_len);
4160
4161 let sat_w = plan.params.tiles_x + 1;
4163 for tx in 0..sat_w {
4164 assert_eq!(plan.sat[tx], 0, "SAT top border should be 0");
4165 }
4166 for ty in 0..plan.params.tiles_y + 1 {
4167 assert_eq!(plan.sat[ty * sat_w], 0, "SAT left border should be 0");
4168 }
4169 }
4170 _ => unreachable!("expected UseTiles"),
4171 }
4172 }
4173
4174 #[test]
4179 fn dense_tiles_exact_threshold_triggers_fallback() {
4180 let mut builder = TileDiffBuilder::new();
4181 let w = 32u16;
4182 let h = 32u16;
4183 let total_cells = (w as usize) * (h as usize);
4187 let config = TileDiffConfig {
4188 enabled: true,
4189 tile_w: 8,
4190 tile_h: 8,
4191 skip_clean_rows: false,
4192 min_cells_for_tiles: 0,
4193 dense_cell_ratio: 1.1,
4194 dense_tile_ratio: 0.5,
4195 max_tiles: usize::MAX / 4,
4196 };
4197
4198 let dirty_rows = vec![true; h as usize];
4199 let mut dirty_bits = vec![0u8; total_cells];
4200 for tile_y in 0..2 {
4203 for tile_x in 0..4 {
4204 let x = tile_x * 8;
4205 let y = tile_y * 8;
4206 dirty_bits[y * w as usize + x] = 1;
4207 }
4208 }
4209
4210 let input = TileDiffInput {
4211 width: w,
4212 height: h,
4213 dirty_rows: &dirty_rows,
4214 dirty_bits: &dirty_bits,
4215 dirty_cells: 8,
4216 dirty_all: false,
4217 };
4218
4219 let result = builder.build(&config, input);
4220 assert!(
4221 matches!(
4222 result,
4223 TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseTiles)
4224 ),
4225 "8 of 16 tiles dirty (50%) should trigger DenseTiles with threshold 0.5"
4226 );
4227 }
4228
4229 #[test]
4230 fn dense_tiles_just_below_threshold_passes() {
4231 let mut builder = TileDiffBuilder::new();
4232 let w = 32u16;
4233 let h = 32u16;
4234 let total_cells = (w as usize) * (h as usize);
4237 let config = TileDiffConfig {
4238 enabled: true,
4239 tile_w: 8,
4240 tile_h: 8,
4241 skip_clean_rows: false,
4242 min_cells_for_tiles: 0,
4243 dense_cell_ratio: 1.1,
4244 dense_tile_ratio: 0.5,
4245 max_tiles: usize::MAX / 4,
4246 };
4247
4248 let dirty_rows = vec![true; h as usize];
4249 let mut dirty_bits = vec![0u8; total_cells];
4250 for tile_idx in 0..7 {
4252 let tile_x = tile_idx % 4;
4253 let tile_y = tile_idx / 4;
4254 let x = tile_x * 8;
4255 let y = tile_y * 8;
4256 dirty_bits[y * w as usize + x] = 1;
4257 }
4258
4259 let input = TileDiffInput {
4260 width: w,
4261 height: h,
4262 dirty_rows: &dirty_rows,
4263 dirty_bits: &dirty_bits,
4264 dirty_cells: 7,
4265 dirty_all: false,
4266 };
4267
4268 let result = builder.build(&config, input);
4269 assert!(
4270 matches!(result, TileDiffBuild::UseTiles(_)),
4271 "7 of 16 tiles dirty (43.75%) should use tiles with threshold 0.5"
4272 );
4273 }
4274
4275 #[test]
4280 fn span_diagnostics_with_no_dirty_spans() {
4281 let mut buf = Buffer::new(20, 3);
4282 buf.clear_dirty();
4283 let diag = span_diagnostics(&buf);
4284 assert!(
4286 diag.contains("stats="),
4287 "diagnostics should include stats: {diag}"
4288 );
4289 }
4290
4291 #[test]
4292 fn span_diagnostics_with_dirty_cells() {
4293 let mut buf = Buffer::new(20, 3);
4294 buf.clear_dirty();
4295 buf.set_raw(5, 1, Cell::from_char('X'));
4296 let diag = span_diagnostics(&buf);
4297 assert!(
4298 diag.contains("stats="),
4299 "diagnostics should include stats: {diag}"
4300 );
4301 }
4302
4303 #[test]
4304 fn span_diagnostics_with_full_row() {
4305 let mut buf = Buffer::new(20, 2);
4306 buf.clear_dirty();
4307 for x in 0..20u16 {
4309 buf.set_raw(x, 0, Cell::from_char('X'));
4310 }
4311 let diag = span_diagnostics(&buf);
4312 assert!(
4314 diag.contains("stats="),
4315 "diagnostics should include stats: {diag}"
4316 );
4317 }
4318
4319 #[test]
4324 fn compute_dirty_matches_full_for_single_row_buffer() {
4325 let old = Buffer::new(50, 1);
4326 let mut new = old.clone();
4327 new.set_raw(25, 0, Cell::from_char('M'));
4328
4329 let full = BufferDiff::compute(&old, &new);
4330 let dirty = BufferDiff::compute_dirty(&old, &new);
4331
4332 assert_eq!(full.changes(), dirty.changes());
4333 }
4334
4335 #[test]
4336 fn compute_dirty_matches_full_for_single_column_buffer() {
4337 let old = Buffer::new(1, 50);
4338 let mut new = old.clone();
4339 new.set_raw(0, 25, Cell::from_char('M'));
4340
4341 let full = BufferDiff::compute(&old, &new);
4342 let dirty = BufferDiff::compute_dirty(&old, &new);
4343
4344 assert_eq!(full.changes(), dirty.changes());
4345 }
4346
4347 #[test]
4348 fn tile_config_chain_returns_same_type() {
4349 let config = TileDiffConfig::default()
4351 .with_enabled(true)
4352 .with_tile_size(16, 16)
4353 .with_min_cells_for_tiles(1000)
4354 .with_skip_clean_rows(true)
4355 .with_dense_cell_ratio(0.3)
4356 .with_dense_tile_ratio(0.7)
4357 .with_max_tiles(2048);
4358
4359 assert!(config.enabled);
4360 assert_eq!(config.tile_w, 16);
4361 assert_eq!(config.tile_h, 16);
4362 assert_eq!(config.min_cells_for_tiles, 1000);
4363 assert!(config.skip_clean_rows);
4364 assert!((config.dense_cell_ratio - 0.3).abs() < f64::EPSILON);
4365 assert!((config.dense_tile_ratio - 0.7).abs() < f64::EPSILON);
4366 assert_eq!(config.max_tiles, 2048);
4367 }
4368
4369 #[test]
4370 fn runs_into_reuse_across_multiple_diffs() {
4371 let mut runs_buf: Vec<ChangeRun> = Vec::new();
4373
4374 let old1 = Buffer::new(10, 2);
4376 let mut new1 = Buffer::new(10, 2);
4377 new1.set_raw(0, 0, Cell::from_char('A'));
4378 new1.set_raw(1, 0, Cell::from_char('B'));
4379 let diff1 = BufferDiff::compute(&old1, &new1);
4380 diff1.runs_into(&mut runs_buf);
4381 assert_eq!(runs_buf.len(), 1);
4382 assert_eq!(runs_buf[0], ChangeRun::new(0, 0, 1));
4383
4384 let old2 = Buffer::new(5, 3);
4386 let mut new2 = Buffer::new(5, 3);
4387 new2.set_raw(2, 1, Cell::from_char('X'));
4388 new2.set_raw(4, 2, Cell::from_char('Y'));
4389 let diff2 = BufferDiff::compute(&old2, &new2);
4390 diff2.runs_into(&mut runs_buf);
4391 assert_eq!(runs_buf.len(), 2);
4392 assert_eq!(runs_buf[0], ChangeRun::new(1, 2, 2));
4393 assert_eq!(runs_buf[1], ChangeRun::new(2, 4, 4));
4394 }
4395
4396 #[test]
4397 fn full_diff_zero_dimensions_runs_empty() {
4398 let diff_w0 = BufferDiff::full(0, 5);
4399 assert!(diff_w0.runs().is_empty());
4400
4401 let diff_h0 = BufferDiff::full(5, 0);
4402 assert!(diff_h0.runs().is_empty());
4403
4404 let diff_both = BufferDiff::full(0, 0);
4405 assert!(diff_both.runs().is_empty());
4406 }
4407
4408 #[test]
4409 fn change_run_max_u16_values() {
4410 let run = ChangeRun::new(u16::MAX, 1, u16::MAX);
4412 assert_eq!(run.y, u16::MAX);
4413 assert_eq!(run.x0, 1);
4414 assert_eq!(run.x1, u16::MAX);
4415 assert_eq!(run.len(), usize::from(u16::MAX)); let run2 = ChangeRun::new(u16::MAX, u16::MAX, u16::MAX);
4419 assert_eq!(run2.len(), 1);
4420 }
4421
4422 #[test]
4423 fn tile_diff_equivalence_adjacent_tiles() {
4424 let width = 200u16;
4426 let height = 60u16;
4427 let old = Buffer::new(width, height);
4428 let mut new = old.clone();
4429 new.clear_dirty();
4430
4431 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");
4439 }
4440}
4441
4442#[cfg(test)]
4443mod proptests {
4444 use super::*;
4445 use crate::cell::Cell;
4446 use ftui_core::geometry::Rect;
4447 use proptest::prelude::*;
4448
4449 #[test]
4451 fn diff_apply_produces_target() {
4452 proptest::proptest!(|(
4453 width in 5u16..50,
4454 height in 5u16..30,
4455 num_changes in 0usize..200,
4456 )| {
4457 let old = Buffer::new(width, height);
4459
4460 let mut new = old.clone();
4462 for i in 0..num_changes {
4463 let x = (i * 7 + 3) as u16 % width;
4464 let y = (i * 11 + 5) as u16 % height;
4465 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4466 new.set_raw(x, y, Cell::from_char(ch));
4467 }
4468
4469 let diff = BufferDiff::compute(&old, &new);
4471
4472 let mut result = old.clone();
4474 for (x, y) in diff.iter() {
4475 let cell = *new.get_unchecked(x, y);
4476 result.set_raw(x, y, cell);
4477 }
4478
4479 for y in 0..height {
4481 for x in 0..width {
4482 let result_cell = result.get_unchecked(x, y);
4483 let new_cell = new.get_unchecked(x, y);
4484 prop_assert!(
4485 result_cell.bits_eq(new_cell),
4486 "Mismatch at ({}, {})",
4487 x,
4488 y
4489 );
4490 }
4491 }
4492 });
4493 }
4494
4495 #[test]
4497 fn identical_buffers_empty_diff() {
4498 proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
4499 let buf = Buffer::new(width, height);
4500 let diff = BufferDiff::compute(&buf, &buf);
4501 prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
4502 });
4503 }
4504
4505 #[test]
4507 fn diff_contains_only_real_changes() {
4508 proptest::proptest!(|(
4509 width in 5u16..50,
4510 height in 5u16..30,
4511 num_changes in 0usize..100,
4512 )| {
4513 let old = Buffer::new(width, height);
4514 let mut new = old.clone();
4515
4516 for i in 0..num_changes {
4517 let x = (i * 7 + 3) as u16 % width;
4518 let y = (i * 11 + 5) as u16 % height;
4519 new.set_raw(x, y, Cell::from_char('X'));
4520 }
4521
4522 let diff = BufferDiff::compute(&old, &new);
4523
4524 for (x, y) in diff.iter() {
4526 let old_cell = old.get_unchecked(x, y);
4527 let new_cell = new.get_unchecked(x, y);
4528 prop_assert!(
4529 !old_cell.bits_eq(new_cell),
4530 "Diff includes unchanged cell at ({}, {})",
4531 x,
4532 y
4533 );
4534 }
4535 });
4536 }
4537
4538 #[test]
4540 fn runs_are_contiguous() {
4541 proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
4542 let old = Buffer::new(width, height);
4543 let mut new = old.clone();
4544
4545 for y in 0..height.min(5) {
4547 for x in 0..width.min(10) {
4548 new.set_raw(x, y, Cell::from_char('#'));
4549 }
4550 }
4551
4552 let diff = BufferDiff::compute(&old, &new);
4553 let runs = diff.runs();
4554
4555 for run in runs {
4557 prop_assert!(run.x1 >= run.x0, "Run has invalid range");
4558 prop_assert!(!run.is_empty(), "Run should not be empty");
4559
4560 for x in run.x0..=run.x1 {
4562 let old_cell = old.get_unchecked(x, run.y);
4563 let new_cell = new.get_unchecked(x, run.y);
4564 prop_assert!(
4565 !old_cell.bits_eq(new_cell),
4566 "Run includes unchanged cell at ({}, {})",
4567 x,
4568 run.y
4569 );
4570 }
4571 }
4572 });
4573 }
4574
4575 #[test]
4577 fn runs_cover_all_changes() {
4578 proptest::proptest!(|(
4579 width in 10u16..60,
4580 height in 5u16..30,
4581 num_changes in 1usize..100,
4582 )| {
4583 let old = Buffer::new(width, height);
4584 let mut new = old.clone();
4585
4586 for i in 0..num_changes {
4587 let x = (i * 13 + 7) as u16 % width;
4588 let y = (i * 17 + 3) as u16 % height;
4589 new.set_raw(x, y, Cell::from_char('X'));
4590 }
4591
4592 let diff = BufferDiff::compute(&old, &new);
4593 let runs = diff.runs();
4594
4595 let mut run_cells: std::collections::HashSet<(u16, u16)> =
4597 std::collections::HashSet::new();
4598 for run in &runs {
4599 for x in run.x0..=run.x1 {
4600 let was_new = run_cells.insert((x, run.y));
4601 prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
4602 }
4603 }
4604
4605 for (x, y) in diff.iter() {
4607 prop_assert!(
4608 run_cells.contains(&(x, y)),
4609 "Change at ({}, {}) not covered by runs",
4610 x,
4611 y
4612 );
4613 }
4614
4615 prop_assert_eq!(
4616 run_cells.len(),
4617 diff.len(),
4618 "Run cell count should match diff change count"
4619 );
4620 });
4621 }
4622
4623 #[test]
4627 fn block_scan_matches_scalar() {
4628 proptest::proptest!(|(
4629 width in 1u16..200,
4630 height in 1u16..20,
4631 num_changes in 0usize..200,
4632 )| {
4633 use crate::cell::PackedRgba;
4634
4635 let old = Buffer::new(width, height);
4636 let mut new = Buffer::new(width, height);
4637
4638 for i in 0..num_changes {
4639 let x = (i * 13 + 7) as u16 % width;
4640 let y = (i * 17 + 3) as u16 % height;
4641 let fg = PackedRgba::rgb(
4642 ((i * 31) % 256) as u8,
4643 ((i * 47) % 256) as u8,
4644 ((i * 71) % 256) as u8,
4645 );
4646 new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4647 }
4648
4649 let diff = BufferDiff::compute(&old, &new);
4650
4651 let mut scalar_changes = Vec::new();
4653 for y in 0..height {
4654 for x in 0..width {
4655 let old_cell = old.get_unchecked(x, y);
4656 let new_cell = new.get_unchecked(x, y);
4657 if !old_cell.bits_eq(new_cell) {
4658 scalar_changes.push((x, y));
4659 }
4660 }
4661 }
4662
4663 prop_assert_eq!(
4664 diff.changes(),
4665 &scalar_changes[..],
4666 "Block scan should match scalar scan"
4667 );
4668 });
4669 }
4670
4671 #[test]
4677 fn property_diff_equivalence() {
4678 proptest::proptest!(|(
4679 width in 1u16..120,
4680 height in 1u16..40,
4681 num_changes in 0usize..300,
4682 )| {
4683 let old = Buffer::new(width, height);
4684 let mut new = Buffer::new(width, height);
4685
4686 for i in 0..num_changes {
4688 let x = (i * 13 + 7) as u16 % width;
4689 let y = (i * 17 + 3) as u16 % height;
4690 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4691 let fg = crate::cell::PackedRgba::rgb(
4692 ((i * 31) % 256) as u8,
4693 ((i * 47) % 256) as u8,
4694 ((i * 71) % 256) as u8,
4695 );
4696 new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
4697 }
4698
4699 let full = BufferDiff::compute(&old, &new);
4700 let dirty = BufferDiff::compute_dirty(&old, &new);
4701
4702 prop_assert_eq!(
4703 full.changes(),
4704 dirty.changes(),
4705 "dirty diff must match full diff (width={}, height={}, changes={})",
4706 width,
4707 height,
4708 num_changes
4709 );
4710
4711 let full_runs = full.runs();
4713 let dirty_runs = dirty.runs();
4714 prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
4715 for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
4716 prop_assert_eq!(fr, dr, "run mismatch");
4717 }
4718 });
4719 }
4720
4721 #[test]
4724 fn property_diff_equivalence_complex_spans() {
4725 proptest::proptest!(|(
4726 width in 10u16..100,
4727 height in 10u16..50,
4728 ops in proptest::collection::vec(
4729 prop_oneof![
4730 (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
4732 (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
4734 ],
4735 1..50
4736 )
4737 )| {
4738 let old = Buffer::new(width, height);
4739 let mut new = Buffer::new(width, height);
4740
4741 new.clear_dirty();
4743
4744 for (op_type, x, y, ch) in ops {
4745 let x = x % width;
4746 let y = y % height;
4747 let cell = Cell::from_char(ch);
4748
4749 match op_type {
4750 0 => new.set(x, y, cell),
4751 1 => {
4752 let w = ((x + 10).min(width) - x).max(1);
4754 let h = ((y + 5).min(height) - y).max(1);
4755 new.fill(Rect::new(x, y, w, h), cell);
4756 }
4757 _ => unreachable!(),
4758 }
4759 }
4760
4761 let full = BufferDiff::compute(&old, &new);
4762 let dirty = BufferDiff::compute_dirty(&old, &new);
4763
4764 prop_assert_eq!(
4765 full.changes(),
4766 dirty.changes(),
4767 "dirty diff (spans) must match full diff; {}",
4768 super::span_diagnostics(&new)
4769 );
4770 });
4771 }
4772
4773 #[test]
4781 fn diff_is_idempotent() {
4782 proptest::proptest!(|(
4783 width in 5u16..60,
4784 height in 5u16..30,
4785 num_changes in 0usize..100,
4786 )| {
4787 let mut buf_a = Buffer::new(width, height);
4788 let mut buf_b = Buffer::new(width, height);
4789
4790 for i in 0..num_changes {
4792 let x = (i * 13 + 7) as u16 % width;
4793 let y = (i * 17 + 3) as u16 % height;
4794 buf_b.set_raw(x, y, Cell::from_char('X'));
4795 }
4796
4797 let diff = BufferDiff::compute(&buf_a, &buf_b);
4799
4800 for (x, y) in diff.iter() {
4802 let cell = *buf_b.get_unchecked(x, y);
4803 buf_a.set_raw(x, y, cell);
4804 }
4805
4806 let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
4808 prop_assert!(
4809 diff_after_first.is_empty(),
4810 "After applying diff once, buffers should be identical (diff was {} changes)",
4811 diff_after_first.len()
4812 );
4813
4814 let before_second = buf_a.clone();
4816 for (x, y) in diff.iter() {
4817 let cell = *buf_b.get_unchecked(x, y);
4818 buf_a.set_raw(x, y, cell);
4819 }
4820
4821 let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
4823 prop_assert!(
4824 diff_after_second.is_empty(),
4825 "Second diff application should be a no-op"
4826 );
4827 });
4828 }
4829
4830 #[test]
4843 fn no_ghosting_after_clear() {
4844 proptest::proptest!(|(
4845 width in 10u16..80,
4846 height in 5u16..30,
4847 num_content_cells in 1usize..200,
4848 )| {
4849 let old = Buffer::new(width, height);
4851
4852 let mut new = Buffer::new(width, height);
4854 let mut expected_changes = std::collections::HashSet::new();
4855
4856 for i in 0..num_content_cells {
4857 let x = (i * 13 + 7) as u16 % width;
4858 let y = (i * 17 + 3) as u16 % height;
4859 new.set_raw(x, y, Cell::from_char('#'));
4860 expected_changes.insert((x, y));
4861 }
4862
4863 let diff = BufferDiff::compute(&old, &new);
4864
4865 for (x, y) in expected_changes {
4868 let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
4869 prop_assert!(
4870 in_diff,
4871 "Content cell at ({}, {}) missing from diff - would ghost",
4872 x,
4873 y
4874 );
4875 }
4876
4877 for (x, y) in diff.iter() {
4879 let old_cell = old.get_unchecked(x, y);
4880 let new_cell = new.get_unchecked(x, y);
4881 prop_assert!(
4882 !old_cell.bits_eq(new_cell),
4883 "Diff includes unchanged cell at ({}, {})",
4884 x,
4885 y
4886 );
4887 }
4888 });
4889 }
4890
4891 #[test]
4899 fn diff_changes_are_monotonic() {
4900 proptest::proptest!(|(
4901 width in 10u16..80,
4902 height in 5u16..30,
4903 num_changes in 1usize..200,
4904 )| {
4905 let old = Buffer::new(width, height);
4906 let mut new = old.clone();
4907
4908 for i in 0..num_changes {
4910 let x = (i * 37 + 11) as u16 % width;
4911 let y = (i * 53 + 7) as u16 % height;
4912 new.set_raw(x, y, Cell::from_char('M'));
4913 }
4914
4915 let diff = BufferDiff::compute(&old, &new);
4916 let changes: Vec<_> = diff.iter().collect();
4917
4918 for window in changes.windows(2) {
4920 let (x1, y1) = window[0];
4921 let (x2, y2) = window[1];
4922
4923 let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
4924 prop_assert!(
4925 is_monotonic,
4926 "Changes not monotonic: ({}, {}) should come before ({}, {})",
4927 x1,
4928 y1,
4929 x2,
4930 y2
4931 );
4932 }
4933 });
4934 }
4935}
4936
4937#[cfg(test)]
4938mod span_edge_cases {
4939 use super::*;
4940 use crate::cell::Cell;
4941 use proptest::prelude::*;
4942
4943 #[test]
4944 fn test_span_diff_u16_max_width() {
4945 let width = 65000;
4947 let height = 1;
4948 let old = Buffer::new(width, height);
4949 let mut new = Buffer::new(width, height);
4950
4951 new.clear_dirty();
4953
4954 new.set_raw(0, 0, Cell::from_char('A'));
4956 new.set_raw(32500, 0, Cell::from_char('B'));
4957 new.set_raw(64999, 0, Cell::from_char('C'));
4958
4959 let full = BufferDiff::compute(&old, &new);
4960 let dirty = BufferDiff::compute_dirty(&old, &new);
4961
4962 assert_eq!(full.changes(), dirty.changes());
4963 assert_eq!(full.len(), 3);
4964
4965 let changes = full.changes();
4967 assert!(changes.contains(&(0, 0)));
4968 assert!(changes.contains(&(32500, 0)));
4969 assert!(changes.contains(&(64999, 0)));
4970 }
4971
4972 #[test]
4973 fn test_span_full_row_dirty_overflow() {
4974 let width = 1000;
4975 let height = 1;
4976 let old = Buffer::new(width, height);
4977 let mut new = Buffer::new(width, height);
4978 new.clear_dirty(); for i in 0..70 {
4983 let x = (i * 10) as u16;
4984 new.set_raw(x, 0, Cell::from_char('X'));
4985 }
4986
4987 let stats = new.dirty_span_stats();
4989 assert!(
4990 stats.rows_full_dirty > 0,
4991 "Should have overflowed to full row"
4992 );
4993 assert_eq!(
4994 stats.rows_with_spans, 0,
4995 "Should have cleared spans on overflow"
4996 );
4997
4998 let full = BufferDiff::compute(&old, &new);
4999 let dirty = BufferDiff::compute_dirty(&old, &new);
5000
5001 assert_eq!(full.changes(), dirty.changes());
5002 assert_eq!(full.len(), 70);
5003 }
5004
5005 #[test]
5006 fn test_span_diff_empty_rows() {
5007 let width = 100;
5008 let height = 10;
5009 let old = Buffer::new(width, height);
5010 let mut new = Buffer::new(width, height);
5011 new.clear_dirty(); let dirty = BufferDiff::compute_dirty(&old, &new);
5015 assert!(dirty.is_empty());
5016 }
5017
5018 proptest! {
5019 #[test]
5020 fn property_span_diff_equivalence_large(
5021 width in 1000u16..5000,
5022 height in 10u16..50,
5023 changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
5024 ) {
5025 let w = width.min(2000);
5027 let h = height.min(50);
5028
5029 let old = Buffer::new(w, h);
5030 let mut new = Buffer::new(w, h);
5031 new.clear_dirty();
5032
5033 for (raw_x, raw_y) in changes {
5035 let x = raw_x % w;
5036 let y = raw_y % h;
5037 new.set_raw(x, y, Cell::from_char('Z'));
5038 }
5039
5040 let full = BufferDiff::compute(&old, &new);
5041 let dirty = BufferDiff::compute_dirty(&old, &new);
5042
5043 prop_assert_eq!(
5044 full.changes(),
5045 dirty.changes(),
5046 "Large buffer mismatch: w={}, h={}, {}",
5047 w,
5048 h,
5049 super::span_diagnostics(&new)
5050 );
5051 }
5052 }
5053
5054 #[test]
5059 fn certified_skip_produces_empty_diff() {
5060 let old = Buffer::new(10, 5);
5061 let new = Buffer::new(10, 5);
5062 let mut diff = BufferDiff::new();
5063
5064 diff.compute_certified_into(&old, &new, DiffSkipHint::SkipDiff);
5065 assert!(diff.is_empty(), "SkipDiff should produce zero changes");
5066 }
5067
5068 #[test]
5069 fn certified_full_diff_matches_standard() {
5070 let old = Buffer::new(10, 5);
5071 let mut new = Buffer::new(10, 5);
5072 new.set(3, 2, Cell::from_char('X'));
5073
5074 let mut standard = BufferDiff::new();
5075 standard.compute_dirty_into(&old, &new);
5076
5077 let mut certified = BufferDiff::new();
5078 certified.compute_certified_into(&old, &new, DiffSkipHint::FullDiff);
5079
5080 assert_eq!(
5081 standard.changes(),
5082 certified.changes(),
5083 "FullDiff hint should produce identical results to standard dirty diff"
5084 );
5085 }
5086
5087 #[test]
5088 fn certified_narrow_to_rows_only_diffs_specified_rows() {
5089 let old = Buffer::new(10, 5);
5090 let mut new = Buffer::new(10, 5);
5091 new.set(2, 1, Cell::from_char('A'));
5093 new.set(5, 3, Cell::from_char('B'));
5094 new.set(7, 4, Cell::from_char('C'));
5096
5097 let mut diff = BufferDiff::new();
5098 diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![1, 3]));
5099
5100 let changes = diff.changes();
5102 assert!(
5103 changes.iter().any(|&(x, y)| x == 2 && y == 1),
5104 "should find change at (2, 1)"
5105 );
5106 assert!(
5107 changes.iter().any(|&(x, y)| x == 5 && y == 3),
5108 "should find change at (5, 3)"
5109 );
5110 assert!(
5111 !changes.iter().any(|&(_, y)| y == 4),
5112 "should NOT find changes in row 4 (not in hint)"
5113 );
5114 }
5115
5116 #[test]
5117 fn certified_narrow_skips_clean_rows() {
5118 let old = Buffer::new(10, 5);
5119 let new = Buffer::new(10, 5);
5120
5121 let mut diff = BufferDiff::new();
5122 diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![0, 2, 4]));
5123
5124 assert!(
5125 diff.is_empty(),
5126 "narrowing to clean rows should produce zero changes"
5127 );
5128 }
5129
5130 #[test]
5131 fn certified_narrow_unsorted_duplicate_rows_yield_sorted_unique_changes() {
5132 let old = Buffer::new(10, 5);
5137 let mut new = Buffer::new(10, 5);
5138 new.set(2, 1, Cell::from_char('A'));
5139 new.set(5, 3, Cell::from_char('B'));
5140
5141 let mut diff = BufferDiff::new();
5142 diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![3, 1, 3, 3]));
5143
5144 assert_eq!(
5145 diff.changes(),
5146 &[(2, 1), (5, 3)],
5147 "changes must be (y,x)-sorted and duplicate-free"
5148 );
5149 let runs = diff.runs();
5150 assert_eq!(runs.len(), 2, "each change coalesces into exactly one run");
5151 }
5152
5153 #[test]
5154 fn fill_full_and_clear_drop_stale_tile_stats() {
5155 let old = Buffer::new(8, 2);
5158 let mut new = Buffer::new(8, 2);
5159 new.clear_dirty();
5160 new.set(3, 0, Cell::from_char('x'));
5161
5162 let mut diff = BufferDiff::new();
5163 diff.compute_dirty_into(&old, &new);
5164 diff.fill_full(8, 2);
5167 assert!(diff.last_tile_stats().is_none(), "fill_full kept stats");
5168
5169 diff.compute_dirty_into(&old, &new);
5170 diff.clear();
5171 assert!(diff.last_tile_stats().is_none(), "clear kept stats");
5172 }
5173
5174 #[test]
5175 fn certified_narrow_out_of_bounds_rows_ignored() {
5176 let old = Buffer::new(10, 5);
5177 let mut new = Buffer::new(10, 5);
5178 new.set(0, 0, Cell::from_char('X'));
5179
5180 let mut diff = BufferDiff::new();
5181 diff.compute_certified_into(
5182 &old,
5183 &new,
5184 DiffSkipHint::NarrowToRows(vec![0, 100, 200]), );
5186
5187 assert_eq!(diff.len(), 1, "should find the one change in row 0");
5188 assert_eq!(diff.changes()[0], (0, 0));
5189 }
5190
5191 #[test]
5192 fn diff_skip_hint_labels() {
5193 assert_eq!(DiffSkipHint::FullDiff.label(), "full-diff");
5194 assert_eq!(DiffSkipHint::SkipDiff.label(), "skip-diff");
5195 assert_eq!(DiffSkipHint::NarrowToRows(vec![]).label(), "narrow-to-rows");
5196 }
5197
5198 #[test]
5199 fn diff_skip_hint_skips_work() {
5200 assert!(!DiffSkipHint::FullDiff.skips_work());
5201 assert!(DiffSkipHint::SkipDiff.skips_work());
5202 assert!(DiffSkipHint::NarrowToRows(vec![1]).skips_work());
5203 }
5204}