Skip to main content

ftui_render/
diff.rs

1#![forbid(unsafe_code)]
2
3//! Diff computation between buffers.
4//!
5//! The `BufferDiff` computes the minimal set of changed cells between two
6//! buffers using a row-major scan for optimal cache efficiency.
7//!
8//! # Algorithm
9//!
10//! Row-major scan for cache efficiency:
11//! 1. Iterate y from 0 to height
12//! 2. Fast-path skip rows where the full slice is equal
13//! 3. For changed rows, scan in coarse blocks and skip unchanged blocks
14//! 4. Within dirty blocks, compare cells using `bits_eq`
15//!
16//! This ensures sequential memory access since cells are stored row-by-row.
17//! With 4 cells per cache line, the prefetcher can anticipate next access.
18//!
19//! # Usage
20//!
21//! ```
22//! use ftui_render::buffer::Buffer;
23//! use ftui_render::cell::Cell;
24//! use ftui_render::diff::BufferDiff;
25//!
26//! let mut old = Buffer::new(80, 24);
27//! let mut new = Buffer::new(80, 24);
28//!
29//! // Make some changes
30//! new.set_raw(5, 5, Cell::from_char('X'));
31//! new.set_raw(6, 5, Cell::from_char('Y'));
32//!
33//! let diff = BufferDiff::compute(&old, &new);
34//! assert_eq!(diff.len(), 2);
35//!
36//! // Coalesce into runs for efficient emission
37//! let runs = diff.runs();
38//! assert_eq!(runs.len(), 1); // Adjacent cells form one run
39//! ```
40
41use crate::buffer::{Buffer, DirtySpan};
42use crate::cell::Cell;
43
44// =============================================================================
45// Block-based Row Scanning (autovec-friendly)
46// =============================================================================
47
48/// Block size for vectorized comparison (4 cells = 64 bytes).
49/// Chosen to match common SIMD register width (256-bit / 512-bit).
50const BLOCK_SIZE: usize = 4;
51
52/// Row block size for coarse blockwise skip (32 cells = 512 bytes).
53/// This lets us skip large unchanged regions in sparse rows while
54/// preserving row-major iteration order.
55const ROW_BLOCK_SIZE: usize = 32;
56
57#[cfg(test)]
58fn span_diagnostics(buf: &Buffer) -> String {
59    let stats = buf.dirty_span_stats();
60    if !buf.dirty_span_config().enabled {
61        return format!("dirty spans disabled; stats={stats:?}");
62    }
63    let mut rows = Vec::new();
64    for y in 0..buf.height() {
65        let Some(row) = buf.dirty_span_row(y) else {
66            continue;
67        };
68        if row.is_full() {
69            rows.push(format!("{y}:full"));
70            continue;
71        }
72        if row.spans().is_empty() {
73            continue;
74        }
75        let spans = row
76            .spans()
77            .iter()
78            .map(|span| format!("[{}, {})", span.x0, span.x1))
79            .collect::<Vec<_>>()
80            .join(",");
81        rows.push(format!("{y}:{spans}"));
82    }
83    format!("stats={stats:?} rows=[{}]", rows.join(" | "))
84}
85
86/// Scan a row slice for changed cells, appending positions to `changes`.
87///
88/// `x_offset` is added to each recorded x coordinate.
89#[inline]
90fn scan_row_changes_range(
91    old_row: &[Cell],
92    new_row: &[Cell],
93    y: u16,
94    x_offset: u16,
95    changes: &mut Vec<(u16, u16)>,
96) {
97    debug_assert_eq!(old_row.len(), new_row.len());
98    let len = old_row.len();
99    let blocks = len / BLOCK_SIZE;
100    let remainder = len % BLOCK_SIZE;
101
102    // Process full blocks
103    for block_idx in 0..blocks {
104        let base = block_idx * BLOCK_SIZE;
105        let base_x = x_offset + base as u16;
106        let old_block = &old_row[base..base + BLOCK_SIZE];
107        let new_block = &new_row[base..base + BLOCK_SIZE];
108
109        // Compare each cell and push changes directly.
110        // We use a constant loop which the compiler will unroll.
111        for i in 0..BLOCK_SIZE {
112            if !old_block[i].bits_eq(&new_block[i]) {
113                changes.push((base_x + i as u16, y));
114            }
115        }
116    }
117
118    // Process remainder cells
119    let rem_base = blocks * BLOCK_SIZE;
120    let rem_base_x = x_offset + rem_base as u16;
121    for i in 0..remainder {
122        if !old_row[rem_base + i].bits_eq(&new_row[rem_base + i]) {
123            changes.push((rem_base_x + i as u16, y));
124        }
125    }
126}
127
128/// Scan a row pair for changed cells, appending positions to `changes`.
129///
130/// Uses coarse block skipping to avoid full per-cell scans in sparse rows,
131/// then falls back to fine-grained scanning inside dirty blocks.
132#[inline]
133fn scan_row_changes(old_row: &[Cell], new_row: &[Cell], y: u16, changes: &mut Vec<(u16, u16)>) {
134    scan_row_changes_blockwise(old_row, new_row, y, changes);
135}
136
137/// Scan a row in coarse blocks, skipping unchanged blocks, and falling back
138/// to fine-grained scan for blocks that differ.
139#[inline]
140fn scan_row_changes_blockwise(
141    old_row: &[Cell],
142    new_row: &[Cell],
143    y: u16,
144    changes: &mut Vec<(u16, u16)>,
145) {
146    debug_assert_eq!(old_row.len(), new_row.len());
147    let len = old_row.len();
148    let blocks = len / ROW_BLOCK_SIZE;
149    let remainder = len % ROW_BLOCK_SIZE;
150
151    for block_idx in 0..blocks {
152        let base = block_idx * ROW_BLOCK_SIZE;
153        let old_block = &old_row[base..base + ROW_BLOCK_SIZE];
154        let new_block = &new_row[base..base + ROW_BLOCK_SIZE];
155        if old_block == new_block {
156            continue;
157        }
158        scan_row_changes_range(old_block, new_block, y, base as u16, changes);
159    }
160
161    if remainder > 0 {
162        let base = blocks * ROW_BLOCK_SIZE;
163        let old_block = &old_row[base..base + remainder];
164        let new_block = &new_row[base..base + remainder];
165        if old_block != new_block {
166            scan_row_changes_range(old_block, new_block, y, base as u16, changes);
167        }
168    }
169}
170
171/// Scan only dirty spans within a row, preserving row-major ordering.
172#[inline]
173fn scan_row_changes_spans(
174    old_row: &[Cell],
175    new_row: &[Cell],
176    y: u16,
177    spans: &[DirtySpan],
178    changes: &mut Vec<(u16, u16)>,
179) {
180    for span in spans {
181        let start = span.x0 as usize;
182        let end = span.x1 as usize;
183        if start >= end || start >= old_row.len() {
184            continue;
185        }
186        let end = end.min(old_row.len());
187        let old_slice = &old_row[start..end];
188        let new_slice = &new_row[start..end];
189        if old_slice == new_slice {
190            continue;
191        }
192        scan_row_changes_range(old_slice, new_slice, y, span.x0, changes);
193    }
194}
195
196#[inline]
197fn scan_row_changes_range_if_needed(
198    old_row: &[Cell],
199    new_row: &[Cell],
200    y: u16,
201    start: usize,
202    end: usize,
203    changes: &mut Vec<(u16, u16)>,
204) {
205    if start >= end || start >= old_row.len() {
206        return;
207    }
208    let end = end.min(old_row.len());
209    let old_slice = &old_row[start..end];
210    let new_slice = &new_row[start..end];
211    if old_slice == new_slice {
212        return;
213    }
214    scan_row_changes_range(old_slice, new_slice, y, start as u16, changes);
215}
216
217#[inline]
218#[allow(clippy::too_many_arguments)]
219fn scan_row_tiles(
220    old_row: &[Cell],
221    new_row: &[Cell],
222    y: u16,
223    width: usize,
224    tile_w: usize,
225    tiles_x: usize,
226    tile_row_base: usize,
227    dirty_tiles: &[bool],
228    changes: &mut Vec<(u16, u16)>,
229) {
230    for tile_x in 0..tiles_x {
231        let tile_idx = tile_row_base + tile_x;
232        if !dirty_tiles[tile_idx] {
233            continue;
234        }
235        let start = tile_x * tile_w;
236        let end = ((tile_x + 1) * tile_w).min(width);
237        scan_row_changes_range_if_needed(old_row, new_row, y, start, end, changes);
238    }
239}
240
241#[inline]
242#[allow(clippy::too_many_arguments)]
243fn scan_row_tiles_spans(
244    old_row: &[Cell],
245    new_row: &[Cell],
246    y: u16,
247    width: usize,
248    tile_w: usize,
249    tiles_x: usize,
250    tile_row_base: usize,
251    dirty_tiles: &[bool],
252    spans: &[DirtySpan],
253    changes: &mut Vec<(u16, u16)>,
254) {
255    if spans.is_empty() {
256        return;
257    }
258    let max_x = width.saturating_sub(1);
259    for span in spans {
260        let span_start = span.x0 as usize;
261        let span_end_exclusive = span.x1 as usize;
262        if span_start >= span_end_exclusive || span_start > max_x {
263            continue;
264        }
265        let span_end = span_end_exclusive.saturating_sub(1).min(max_x);
266        let tile_x_start = span_start / tile_w;
267        let tile_x_end = span_end / tile_w;
268        for tile_x in tile_x_start..=tile_x_end {
269            if tile_x >= tiles_x {
270                break;
271            }
272            let tile_idx = tile_row_base + tile_x;
273            if !dirty_tiles[tile_idx] {
274                continue;
275            }
276            let tile_start = tile_x * tile_w;
277            let tile_end = ((tile_x + 1) * tile_w).min(width);
278            let seg_start = span_start.max(tile_start);
279            let seg_end = span_end.min(tile_end.saturating_sub(1));
280            if seg_start > seg_end {
281                continue;
282            }
283            scan_row_changes_range_if_needed(old_row, new_row, y, seg_start, seg_end + 1, changes);
284        }
285    }
286}
287
288// =============================================================================
289// Tile-based Skip (Summed-Area Table)
290// =============================================================================
291
292const TILE_SIZE_MIN: u16 = 8;
293const TILE_SIZE_MAX: u16 = 64;
294
295#[inline]
296fn clamp_tile_size(value: u16) -> u16 {
297    value.clamp(TILE_SIZE_MIN, TILE_SIZE_MAX)
298}
299
300#[inline]
301fn div_ceil_usize(n: usize, d: usize) -> usize {
302    debug_assert!(d > 0);
303    n.div_ceil(d)
304}
305
306/// Configuration for tile-based diff skipping.
307#[derive(Debug, Clone)]
308pub struct TileDiffConfig {
309    /// Whether tile-based skipping is enabled.
310    pub enabled: bool,
311    /// Tile width in cells (clamped to [8, 64]).
312    pub tile_w: u16,
313    /// Tile height in cells (clamped to [8, 64]).
314    pub tile_h: u16,
315    /// Minimum total cells required before enabling tiles.
316    pub min_cells_for_tiles: usize,
317    /// Dense cell ratio threshold for falling back to non-tile diff.
318    pub dense_cell_ratio: f64,
319    /// Dense tile ratio threshold for falling back to non-tile diff.
320    pub dense_tile_ratio: f64,
321    /// Maximum number of tiles allowed (SAT build budget; fallback if exceeded).
322    pub max_tiles: usize,
323}
324
325impl Default for TileDiffConfig {
326    fn default() -> Self {
327        Self {
328            enabled: true,
329            tile_w: 16,
330            tile_h: 8,
331            min_cells_for_tiles: 12_000,
332            dense_cell_ratio: 0.25,
333            dense_tile_ratio: 0.60,
334            max_tiles: 4096,
335        }
336    }
337}
338
339impl TileDiffConfig {
340    /// Toggle tile-based skipping.
341    pub fn with_enabled(mut self, enabled: bool) -> Self {
342        self.enabled = enabled;
343        self
344    }
345
346    /// Set tile size in cells (clamped during build).
347    pub fn with_tile_size(mut self, tile_w: u16, tile_h: u16) -> Self {
348        self.tile_w = tile_w;
349        self.tile_h = tile_h;
350        self
351    }
352
353    /// Set minimum cell count required before tiles are considered.
354    pub fn with_min_cells_for_tiles(mut self, min_cells: usize) -> Self {
355        self.min_cells_for_tiles = min_cells;
356        self
357    }
358
359    /// Set dense cell ratio threshold for falling back to non-tile diff.
360    pub fn with_dense_cell_ratio(mut self, ratio: f64) -> Self {
361        self.dense_cell_ratio = ratio;
362        self
363    }
364
365    /// Set dense tile ratio threshold for falling back to non-tile diff.
366    pub fn with_dense_tile_ratio(mut self, ratio: f64) -> Self {
367        self.dense_tile_ratio = ratio;
368        self
369    }
370
371    /// Set SAT build budget via maximum tiles allowed.
372    pub fn with_max_tiles(mut self, max_tiles: usize) -> Self {
373        self.max_tiles = max_tiles;
374        self
375    }
376}
377
378/// Reason the tile path fell back to a non-tile diff.
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub enum TileDiffFallback {
381    Disabled,
382    SmallScreen,
383    DirtyAll,
384    DenseCells,
385    DenseTiles,
386    TooManyTiles,
387    Overflow,
388}
389
390impl TileDiffFallback {
391    pub const fn as_str(self) -> &'static str {
392        match self {
393            Self::Disabled => "disabled",
394            Self::SmallScreen => "small_screen",
395            Self::DirtyAll => "dirty_all",
396            Self::DenseCells => "dense_cells",
397            Self::DenseTiles => "dense_tiles",
398            Self::TooManyTiles => "too_many_tiles",
399            Self::Overflow => "overflow",
400        }
401    }
402}
403
404/// Tile parameters derived from the current buffer dimensions.
405#[derive(Debug, Clone, Copy)]
406pub struct TileParams {
407    pub width: u16,
408    pub height: u16,
409    pub tile_w: u16,
410    pub tile_h: u16,
411    pub tiles_x: usize,
412    pub tiles_y: usize,
413}
414
415impl TileParams {
416    #[inline]
417    pub fn total_tiles(self) -> usize {
418        self.tiles_x * self.tiles_y
419    }
420
421    #[inline]
422    pub fn total_cells(self) -> usize {
423        self.width as usize * self.height as usize
424    }
425}
426
427/// Summary statistics from building a tile SAT.
428#[derive(Debug, Clone, Copy)]
429pub struct TileDiffStats {
430    pub width: u16,
431    pub height: u16,
432    pub tile_w: u16,
433    pub tile_h: u16,
434    pub tiles_x: usize,
435    pub tiles_y: usize,
436    pub total_tiles: usize,
437    pub dirty_cells: usize,
438    pub dirty_tiles: usize,
439    pub dirty_cell_ratio: f64,
440    pub dirty_tile_ratio: f64,
441    pub scanned_tiles: usize,
442    pub skipped_tiles: usize,
443    pub sat_build_cells: usize,
444    pub scan_cells_estimate: usize,
445    pub fallback: Option<TileDiffFallback>,
446}
447
448/// Reusable builder for tile counts and SAT.
449#[derive(Debug, Default, Clone)]
450pub struct TileDiffBuilder {
451    tile_counts: Vec<u32>,
452    sat: Vec<u32>,
453    dirty_tiles: Vec<bool>,
454}
455
456/// Successful tile build with reusable buffers.
457#[derive(Debug, Clone)]
458pub struct TileDiffPlan<'a> {
459    pub params: TileParams,
460    pub stats: TileDiffStats,
461    pub dirty_tiles: &'a [bool],
462    pub tile_counts: &'a [u32],
463    pub sat: &'a [u32],
464}
465
466/// Result of a tile build attempt.
467#[derive(Debug, Clone)]
468pub enum TileDiffBuild<'a> {
469    UseTiles(TileDiffPlan<'a>),
470    Fallback(TileDiffStats),
471}
472
473impl TileDiffBuilder {
474    pub fn new() -> Self {
475        Self::default()
476    }
477
478    pub fn build<'a>(
479        &'a mut self,
480        config: &TileDiffConfig,
481        width: u16,
482        height: u16,
483        dirty_bits: &[u8],
484        dirty_cells: usize,
485        dirty_all: bool,
486    ) -> TileDiffBuild<'a> {
487        let tile_w = clamp_tile_size(config.tile_w);
488        let tile_h = clamp_tile_size(config.tile_h);
489        let width_usize = width as usize;
490        let height_usize = height as usize;
491        let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
492        let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
493        let total_tiles = tiles_x * tiles_y;
494        let total_cells = width_usize * height_usize;
495        let dirty_cell_ratio = if total_cells == 0 {
496            0.0
497        } else {
498            dirty_cells as f64 / total_cells as f64
499        };
500
501        let mut stats = TileDiffStats {
502            width,
503            height,
504            tile_w,
505            tile_h,
506            tiles_x,
507            tiles_y,
508            total_tiles,
509            dirty_cells,
510            dirty_tiles: 0,
511            dirty_cell_ratio,
512            dirty_tile_ratio: 0.0,
513            scanned_tiles: 0,
514            skipped_tiles: total_tiles,
515            sat_build_cells: 0,
516            scan_cells_estimate: 0,
517            fallback: None,
518        };
519
520        if !config.enabled {
521            stats.fallback = Some(TileDiffFallback::Disabled);
522            return TileDiffBuild::Fallback(stats);
523        }
524
525        if total_cells < config.min_cells_for_tiles {
526            stats.fallback = Some(TileDiffFallback::SmallScreen);
527            return TileDiffBuild::Fallback(stats);
528        }
529
530        if dirty_all {
531            stats.fallback = Some(TileDiffFallback::DirtyAll);
532            return TileDiffBuild::Fallback(stats);
533        }
534
535        if dirty_cell_ratio >= config.dense_cell_ratio {
536            stats.fallback = Some(TileDiffFallback::DenseCells);
537            return TileDiffBuild::Fallback(stats);
538        }
539
540        if total_tiles > config.max_tiles {
541            stats.fallback = Some(TileDiffFallback::TooManyTiles);
542            return TileDiffBuild::Fallback(stats);
543        }
544
545        debug_assert_eq!(dirty_bits.len(), total_cells);
546        if dirty_bits.len() < total_cells {
547            stats.fallback = Some(TileDiffFallback::Overflow);
548            return TileDiffBuild::Fallback(stats);
549        }
550
551        self.tile_counts.resize(total_tiles, 0);
552        self.tile_counts.fill(0);
553        self.dirty_tiles.resize(total_tiles, false);
554        self.dirty_tiles.fill(false);
555
556        let tile_w_usize = tile_w as usize;
557        let tile_h_usize = tile_h as usize;
558        let mut overflow = false;
559
560        for y in 0..height_usize {
561            let row_start = y * width_usize;
562            let tile_y = y / tile_h_usize;
563            for x in 0..width_usize {
564                let idx = row_start + x;
565                if dirty_bits[idx] == 0 {
566                    continue;
567                }
568                let tile_x = x / tile_w_usize;
569                let tile_idx = tile_y * tiles_x + tile_x;
570                match self.tile_counts[tile_idx].checked_add(1) {
571                    Some(value) => self.tile_counts[tile_idx] = value,
572                    None => {
573                        overflow = true;
574                        break;
575                    }
576                }
577            }
578            if overflow {
579                break;
580            }
581        }
582
583        if overflow {
584            stats.fallback = Some(TileDiffFallback::Overflow);
585            return TileDiffBuild::Fallback(stats);
586        }
587
588        let mut dirty_tiles = 0usize;
589        for (idx, count) in self.tile_counts.iter().enumerate() {
590            if *count > 0 {
591                self.dirty_tiles[idx] = true;
592                dirty_tiles += 1;
593            }
594        }
595
596        stats.dirty_tiles = dirty_tiles;
597        stats.dirty_tile_ratio = if total_tiles == 0 {
598            0.0
599        } else {
600            dirty_tiles as f64 / total_tiles as f64
601        };
602        stats.scanned_tiles = dirty_tiles;
603        stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
604        stats.sat_build_cells = total_cells;
605        stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
606
607        if stats.dirty_tile_ratio >= config.dense_tile_ratio {
608            stats.fallback = Some(TileDiffFallback::DenseTiles);
609            return TileDiffBuild::Fallback(stats);
610        }
611
612        let sat_w = tiles_x + 1;
613        let sat_h = tiles_y + 1;
614        let sat_len = sat_w * sat_h;
615        self.sat.resize(sat_len, 0);
616        self.sat.fill(0);
617
618        for ty in 0..tiles_y {
619            let row_base = (ty + 1) * sat_w;
620            let prev_base = ty * sat_w;
621            for tx in 0..tiles_x {
622                let count = self.tile_counts[ty * tiles_x + tx] as u64;
623                let above = self.sat[prev_base + tx + 1] as u64;
624                let left = self.sat[row_base + tx] as u64;
625                let diag = self.sat[prev_base + tx] as u64;
626                let value = count + above + left - diag;
627                if value > u32::MAX as u64 {
628                    stats.fallback = Some(TileDiffFallback::Overflow);
629                    return TileDiffBuild::Fallback(stats);
630                }
631                self.sat[row_base + tx + 1] = value as u32;
632            }
633        }
634
635        let params = TileParams {
636            width,
637            height,
638            tile_w,
639            tile_h,
640            tiles_x,
641            tiles_y,
642        };
643
644        TileDiffBuild::UseTiles(TileDiffPlan {
645            params,
646            stats,
647            dirty_tiles: &self.dirty_tiles,
648            tile_counts: &self.tile_counts,
649            sat: &self.sat,
650        })
651    }
652}
653
654#[inline]
655fn reserve_changes_capacity(width: u16, height: u16, changes: &mut Vec<(u16, u16)>) {
656    // Estimate capacity: assume ~5% of cells change on average.
657    let estimated_changes = (width as usize * height as usize) / 20;
658    let additional = estimated_changes.saturating_sub(changes.len());
659    if additional > 0 {
660        changes.reserve(additional);
661    }
662}
663
664fn compute_changes(old: &Buffer, new: &Buffer, changes: &mut Vec<(u16, u16)>) {
665    #[cfg(feature = "tracing")]
666    let _span = tracing::debug_span!("diff_compute", width = old.width(), height = old.height());
667    #[cfg(feature = "tracing")]
668    let _guard = _span.enter();
669
670    assert_eq!(old.width(), new.width(), "buffer widths must match");
671    assert_eq!(old.height(), new.height(), "buffer heights must match");
672
673    let width = old.width();
674    let height = old.height();
675    let w = width as usize;
676
677    changes.clear();
678    reserve_changes_capacity(width, height, changes);
679
680    let old_cells = old.cells();
681    let new_cells = new.cells();
682
683    // Row-major scan with row-skip fast path
684    for y in 0..height {
685        let row_start = y as usize * w;
686        let old_row = &old_cells[row_start..row_start + w];
687        let new_row = &new_cells[row_start..row_start + w];
688
689        // Scan for changed cells using blockwise row scan.
690        // This avoids a full-row equality pre-scan and prevents
691        // double-scanning rows that contain changes.
692        scan_row_changes(old_row, new_row, y, changes);
693    }
694
695    #[cfg(feature = "tracing")]
696    tracing::trace!(changes = changes.len(), "diff computed");
697}
698
699fn compute_dirty_changes(
700    old: &Buffer,
701    new: &Buffer,
702    changes: &mut Vec<(u16, u16)>,
703    tile_builder: &mut TileDiffBuilder,
704    tile_config: &TileDiffConfig,
705    tile_stats_out: &mut Option<TileDiffStats>,
706) {
707    assert_eq!(old.width(), new.width(), "buffer widths must match");
708    assert_eq!(old.height(), new.height(), "buffer heights must match");
709
710    let width = old.width();
711    let height = old.height();
712    let w = width as usize;
713
714    changes.clear();
715    reserve_changes_capacity(width, height, changes);
716
717    let old_cells = old.cells();
718    let new_cells = new.cells();
719    let dirty = new.dirty_rows();
720
721    *tile_stats_out = None;
722    let tile_build = tile_builder.build(
723        tile_config,
724        width,
725        height,
726        new.dirty_bits(),
727        new.dirty_cell_count(),
728        new.dirty_all(),
729    );
730
731    if let TileDiffBuild::UseTiles(plan) = tile_build {
732        *tile_stats_out = Some(plan.stats);
733        let tile_w = plan.params.tile_w as usize;
734        let tile_h = plan.params.tile_h as usize;
735        let tiles_x = plan.params.tiles_x;
736        let dirty_tiles = plan.dirty_tiles;
737
738        for y in 0..height {
739            if !dirty[y as usize] {
740                continue;
741            }
742
743            let row_start = y as usize * w;
744            let old_row = &old_cells[row_start..row_start + w];
745            let new_row = &new_cells[row_start..row_start + w];
746
747            if old_row == new_row {
748                continue;
749            }
750
751            let tile_y = y as usize / tile_h;
752            let tile_row_base = tile_y * tiles_x;
753            debug_assert!(tile_row_base + tiles_x <= dirty_tiles.len());
754
755            let span_row = new.dirty_span_row(y);
756            if let Some(span_row) = span_row {
757                if span_row.is_full() {
758                    scan_row_tiles(
759                        old_row,
760                        new_row,
761                        y,
762                        w,
763                        tile_w,
764                        tiles_x,
765                        tile_row_base,
766                        dirty_tiles,
767                        changes,
768                    );
769                    continue;
770                }
771                let spans = span_row.spans();
772                if spans.is_empty() {
773                    scan_row_tiles(
774                        old_row,
775                        new_row,
776                        y,
777                        w,
778                        tile_w,
779                        tiles_x,
780                        tile_row_base,
781                        dirty_tiles,
782                        changes,
783                    );
784                    continue;
785                }
786                scan_row_tiles_spans(
787                    old_row,
788                    new_row,
789                    y,
790                    w,
791                    tile_w,
792                    tiles_x,
793                    tile_row_base,
794                    dirty_tiles,
795                    spans,
796                    changes,
797                );
798            } else {
799                scan_row_tiles(
800                    old_row,
801                    new_row,
802                    y,
803                    w,
804                    tile_w,
805                    tiles_x,
806                    tile_row_base,
807                    dirty_tiles,
808                    changes,
809                );
810            }
811        }
812        return;
813    }
814
815    if let TileDiffBuild::Fallback(stats) = tile_build {
816        *tile_stats_out = Some(stats);
817    }
818
819    for y in 0..height {
820        // Skip clean rows (the key optimization).
821        if !dirty[y as usize] {
822            continue;
823        }
824
825        let row_start = y as usize * w;
826        let old_row = &old_cells[row_start..row_start + w];
827        let new_row = &new_cells[row_start..row_start + w];
828
829        // Even for dirty rows, row-skip fast path applies:
830        // a row may be marked dirty but end up identical after compositing.
831        if old_row == new_row {
832            continue;
833        }
834
835        let span_row = new.dirty_span_row(y);
836        if let Some(span_row) = span_row {
837            if span_row.is_full() {
838                scan_row_changes(old_row, new_row, y, changes);
839                continue;
840            }
841            let spans = span_row.spans();
842            if spans.is_empty() {
843                scan_row_changes(old_row, new_row, y, changes);
844                continue;
845            }
846            scan_row_changes_spans(old_row, new_row, y, spans, changes);
847        } else {
848            scan_row_changes(old_row, new_row, y, changes);
849        }
850    }
851}
852
853/// A contiguous run of changed cells on a single row.
854///
855/// Used by the presenter to emit efficient cursor positioning.
856/// Instead of positioning for each cell, position once and emit the run.
857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
858pub struct ChangeRun {
859    /// Row index.
860    pub y: u16,
861    /// Start column (inclusive).
862    pub x0: u16,
863    /// End column (inclusive).
864    pub x1: u16,
865}
866
867impl ChangeRun {
868    /// Create a new change run.
869    #[inline]
870    pub const fn new(y: u16, x0: u16, x1: u16) -> Self {
871        debug_assert!(x0 <= x1);
872        Self { y, x0, x1 }
873    }
874
875    /// Number of cells in this run.
876    #[inline]
877    pub const fn len(&self) -> u16 {
878        self.x1 - self.x0 + 1
879    }
880
881    /// Check if this run is empty (should never happen in practice).
882    #[inline]
883    pub const fn is_empty(&self) -> bool {
884        self.x1 < self.x0
885    }
886}
887
888/// The diff between two buffers.
889///
890/// Contains the list of (x, y) positions where cells differ.
891#[derive(Debug, Clone, Default)]
892pub struct BufferDiff {
893    /// List of changed cell positions (x, y).
894    changes: Vec<(u16, u16)>,
895    /// Reusable tile builder for SAT-based diff skipping.
896    tile_builder: TileDiffBuilder,
897    /// Tile diff configuration (thresholds + sizes).
898    tile_config: TileDiffConfig,
899    /// Last tile diagnostics from a dirty diff pass.
900    last_tile_stats: Option<TileDiffStats>,
901}
902
903impl BufferDiff {
904    /// Create an empty diff.
905    pub fn new() -> Self {
906        Self {
907            changes: Vec::new(),
908            tile_builder: TileDiffBuilder::default(),
909            tile_config: TileDiffConfig::default(),
910            last_tile_stats: None,
911        }
912    }
913
914    /// Create a diff with pre-allocated capacity.
915    pub fn with_capacity(capacity: usize) -> Self {
916        let mut diff = Self::new();
917        diff.changes = Vec::with_capacity(capacity);
918        diff
919    }
920
921    /// Create a diff that marks every cell as changed.
922    ///
923    /// Useful for full-screen redraws where the previous buffer is unknown
924    /// (e.g., after resize or initial present).
925    pub fn full(width: u16, height: u16) -> Self {
926        if width == 0 || height == 0 {
927            return Self::new();
928        }
929
930        let total = width as usize * height as usize;
931        let mut changes = Vec::with_capacity(total);
932        for y in 0..height {
933            for x in 0..width {
934                changes.push((x, y));
935            }
936        }
937        let mut diff = Self::new();
938        diff.changes = changes;
939        diff
940    }
941
942    /// Compute the diff between two buffers.
943    ///
944    /// Uses row-major scan for cache efficiency. Both buffers must have
945    /// the same dimensions.
946    ///
947    /// # Optimizations
948    ///
949    /// - **Row-skip fast path**: unchanged rows are detected via slice
950    ///   equality and skipped entirely. For typical UI updates where most
951    ///   rows are static, this eliminates the majority of per-cell work.
952    /// - **Blockwise row scan**: rows with sparse edits are scanned in
953    ///   coarse blocks, skipping unchanged blocks and only diving into
954    ///   the blocks that differ.
955    /// - **Direct slice iteration**: row slices are computed once per row
956    ///   instead of calling `get_unchecked(x, y)` per cell, eliminating
957    ///   repeated `y * width + x` index arithmetic.
958    /// - **Branchless cell comparison**: `bits_eq` uses bitwise AND to
959    ///   avoid branch mispredictions in the inner loop.
960    ///
961    /// # Panics
962    ///
963    /// Debug-asserts that both buffers have identical dimensions.
964    pub fn compute(old: &Buffer, new: &Buffer) -> Self {
965        let mut diff = Self::new();
966        diff.compute_into(old, new);
967        diff
968    }
969
970    /// Compute the diff into an existing buffer to reuse allocation.
971    pub fn compute_into(&mut self, old: &Buffer, new: &Buffer) {
972        self.last_tile_stats = None;
973        compute_changes(old, new, &mut self.changes);
974    }
975
976    /// Compute the diff between two buffers using dirty-row hints.
977    ///
978    /// Only rows marked dirty in `new` are compared cell-by-cell.
979    /// Clean rows are skipped entirely (O(1) per clean row).
980    ///
981    /// This is sound provided the dirty tracking invariant holds:
982    /// for all y, if any cell in row y changed, then `new.is_row_dirty(y)`.
983    ///
984    /// Falls back to full comparison for rows marked dirty, so false positives
985    /// (marking a row dirty when it didn't actually change) are safe — they
986    /// only cost the per-cell scan for that row.
987    pub fn compute_dirty(old: &Buffer, new: &Buffer) -> Self {
988        let mut diff = Self::new();
989        diff.compute_dirty_into(old, new);
990        diff
991    }
992
993    /// Compute the dirty-row diff into an existing buffer to reuse allocation.
994    pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
995        compute_dirty_changes(
996            old,
997            new,
998            &mut self.changes,
999            &mut self.tile_builder,
1000            &self.tile_config,
1001            &mut self.last_tile_stats,
1002        );
1003    }
1004
1005    /// Number of changed cells.
1006    #[inline]
1007    pub fn len(&self) -> usize {
1008        self.changes.len()
1009    }
1010
1011    /// Check if no cells changed.
1012    #[inline]
1013    pub fn is_empty(&self) -> bool {
1014        self.changes.is_empty()
1015    }
1016
1017    /// Get the list of changed positions.
1018    #[inline]
1019    pub fn changes(&self) -> &[(u16, u16)] {
1020        &self.changes
1021    }
1022
1023    /// Access the last tile diagnostics from a dirty diff pass.
1024    #[inline]
1025    pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
1026        self.last_tile_stats
1027    }
1028
1029    /// Mutably access tile diff configuration.
1030    #[inline]
1031    pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
1032        &mut self.tile_config
1033    }
1034
1035    /// Convert point changes into contiguous runs.
1036    ///
1037    /// Consecutive x positions on the same row are coalesced into a single run.
1038    /// This enables efficient cursor positioning in the presenter.
1039    pub fn runs(&self) -> Vec<ChangeRun> {
1040        #[cfg(feature = "tracing")]
1041        let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
1042        #[cfg(feature = "tracing")]
1043        let _guard = _span.enter();
1044
1045        if self.changes.is_empty() {
1046            return Vec::new();
1047        }
1048
1049        // Changes are already sorted by (y, x) from row-major scan
1050        // so we don't need to sort again.
1051        let sorted = &self.changes;
1052        let len = sorted.len();
1053
1054        // Worst case: every change is isolated, so runs == changes.
1055        // Pre-alloc to avoid repeated growth in hot paths.
1056        let mut runs = Vec::with_capacity(len);
1057
1058        let mut i = 0;
1059
1060        while i < len {
1061            let (x0, y) = sorted[i];
1062            let mut x1 = x0;
1063            i += 1;
1064
1065            // Coalesce consecutive x positions on the same row
1066            while i < len {
1067                let (x, yy) = sorted[i];
1068                if yy != y || x != x1.saturating_add(1) {
1069                    break;
1070                }
1071                x1 = x;
1072                i += 1;
1073            }
1074
1075            runs.push(ChangeRun::new(y, x0, x1));
1076        }
1077
1078        #[cfg(feature = "tracing")]
1079        tracing::trace!(run_count = runs.len(), "runs coalesced");
1080
1081        runs
1082    }
1083
1084    /// Iterate over changed positions.
1085    #[inline]
1086    pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
1087        self.changes.iter().copied()
1088    }
1089
1090    /// Clear the diff, removing all recorded changes.
1091    pub fn clear(&mut self) {
1092        self.changes.clear();
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use crate::cell::{Cell, PackedRgba};
1100
1101    #[test]
1102    fn empty_diff_when_buffers_identical() {
1103        let buf1 = Buffer::new(10, 10);
1104        let buf2 = Buffer::new(10, 10);
1105        let diff = BufferDiff::compute(&buf1, &buf2);
1106
1107        assert!(diff.is_empty());
1108        assert_eq!(diff.len(), 0);
1109    }
1110
1111    #[test]
1112    fn full_diff_marks_all_cells() {
1113        let diff = BufferDiff::full(3, 2);
1114        assert_eq!(diff.len(), 6);
1115        assert_eq!(diff.changes()[0], (0, 0));
1116        assert_eq!(diff.changes()[5], (2, 1));
1117    }
1118
1119    #[test]
1120    fn single_cell_change_detected() {
1121        let old = Buffer::new(10, 10);
1122        let mut new = Buffer::new(10, 10);
1123
1124        new.set_raw(5, 5, Cell::from_char('X'));
1125        let diff = BufferDiff::compute(&old, &new);
1126
1127        assert_eq!(diff.len(), 1);
1128        assert_eq!(diff.changes(), &[(5, 5)]);
1129    }
1130
1131    #[test]
1132    fn dirty_row_false_positive_skipped() {
1133        let old = Buffer::new(8, 2);
1134        let mut new = old.clone();
1135
1136        // Clear initial dirty state to isolate this row.
1137        new.clear_dirty();
1138        // Mark row 0 dirty without changing content.
1139        new.set_raw(2, 0, Cell::default());
1140
1141        let diff = BufferDiff::compute_dirty(&old, &new);
1142        assert!(
1143            diff.is_empty(),
1144            "dirty row with no changes should be skipped"
1145        );
1146    }
1147
1148    #[test]
1149    fn multiple_scattered_changes_detected() {
1150        let old = Buffer::new(10, 10);
1151        let mut new = Buffer::new(10, 10);
1152
1153        new.set_raw(0, 0, Cell::from_char('A'));
1154        new.set_raw(9, 9, Cell::from_char('B'));
1155        new.set_raw(5, 3, Cell::from_char('C'));
1156
1157        let diff = BufferDiff::compute(&old, &new);
1158
1159        assert_eq!(diff.len(), 3);
1160        // Sorted by row-major order: (0,0), (5,3), (9,9)
1161        let changes = diff.changes();
1162        assert!(changes.contains(&(0, 0)));
1163        assert!(changes.contains(&(9, 9)));
1164        assert!(changes.contains(&(5, 3)));
1165    }
1166
1167    #[test]
1168    fn runs_coalesces_adjacent_cells() {
1169        let old = Buffer::new(10, 10);
1170        let mut new = Buffer::new(10, 10);
1171
1172        // Three adjacent cells on row 5
1173        new.set_raw(3, 5, Cell::from_char('A'));
1174        new.set_raw(4, 5, Cell::from_char('B'));
1175        new.set_raw(5, 5, Cell::from_char('C'));
1176
1177        let diff = BufferDiff::compute(&old, &new);
1178        let runs = diff.runs();
1179
1180        assert_eq!(runs.len(), 1);
1181        assert_eq!(runs[0].y, 5);
1182        assert_eq!(runs[0].x0, 3);
1183        assert_eq!(runs[0].x1, 5);
1184        assert_eq!(runs[0].len(), 3);
1185    }
1186
1187    #[test]
1188    fn runs_handles_gaps_correctly() {
1189        let old = Buffer::new(10, 10);
1190        let mut new = Buffer::new(10, 10);
1191
1192        // Two groups with a gap
1193        new.set_raw(0, 0, Cell::from_char('A'));
1194        new.set_raw(1, 0, Cell::from_char('B'));
1195        // gap at x=2
1196        new.set_raw(3, 0, Cell::from_char('C'));
1197        new.set_raw(4, 0, Cell::from_char('D'));
1198
1199        let diff = BufferDiff::compute(&old, &new);
1200        let runs = diff.runs();
1201
1202        assert_eq!(runs.len(), 2);
1203
1204        assert_eq!(runs[0].y, 0);
1205        assert_eq!(runs[0].x0, 0);
1206        assert_eq!(runs[0].x1, 1);
1207
1208        assert_eq!(runs[1].y, 0);
1209        assert_eq!(runs[1].x0, 3);
1210        assert_eq!(runs[1].x1, 4);
1211    }
1212
1213    #[test]
1214    fn runs_handles_max_column_without_overflow() {
1215        let mut diff = BufferDiff::new();
1216        diff.changes = vec![(u16::MAX, 0)];
1217
1218        let runs = diff.runs();
1219
1220        assert_eq!(runs.len(), 1);
1221        assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1222    }
1223
1224    #[test]
1225    fn runs_handles_multiple_rows() {
1226        let old = Buffer::new(10, 10);
1227        let mut new = Buffer::new(10, 10);
1228
1229        // Changes on multiple rows
1230        new.set_raw(0, 0, Cell::from_char('A'));
1231        new.set_raw(1, 0, Cell::from_char('B'));
1232        new.set_raw(5, 2, Cell::from_char('C'));
1233        new.set_raw(0, 5, Cell::from_char('D'));
1234
1235        let diff = BufferDiff::compute(&old, &new);
1236        let runs = diff.runs();
1237
1238        assert_eq!(runs.len(), 3);
1239
1240        // Row 0: (0-1)
1241        assert_eq!(runs[0].y, 0);
1242        assert_eq!(runs[0].x0, 0);
1243        assert_eq!(runs[0].x1, 1);
1244
1245        // Row 2: (5)
1246        assert_eq!(runs[1].y, 2);
1247        assert_eq!(runs[1].x0, 5);
1248        assert_eq!(runs[1].x1, 5);
1249
1250        // Row 5: (0)
1251        assert_eq!(runs[2].y, 5);
1252        assert_eq!(runs[2].x0, 0);
1253        assert_eq!(runs[2].x1, 0);
1254    }
1255
1256    #[test]
1257    fn empty_runs_from_empty_diff() {
1258        let diff = BufferDiff::new();
1259        let runs = diff.runs();
1260        assert!(runs.is_empty());
1261    }
1262
1263    #[test]
1264    fn change_run_len() {
1265        let run = ChangeRun::new(0, 5, 10);
1266        assert_eq!(run.len(), 6);
1267
1268        let single = ChangeRun::new(0, 5, 5);
1269        assert_eq!(single.len(), 1);
1270    }
1271
1272    #[test]
1273    fn color_changes_detected() {
1274        let old = Buffer::new(10, 10);
1275        let mut new = Buffer::new(10, 10);
1276
1277        // Same empty content but different color
1278        new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1279
1280        let diff = BufferDiff::compute(&old, &new);
1281        assert_eq!(diff.len(), 1);
1282    }
1283
1284    #[test]
1285    fn diff_iter() {
1286        let old = Buffer::new(5, 5);
1287        let mut new = Buffer::new(5, 5);
1288        new.set_raw(1, 1, Cell::from_char('X'));
1289        new.set_raw(2, 2, Cell::from_char('Y'));
1290
1291        let diff = BufferDiff::compute(&old, &new);
1292        let positions: Vec<_> = diff.iter().collect();
1293
1294        assert_eq!(positions.len(), 2);
1295        assert!(positions.contains(&(1, 1)));
1296        assert!(positions.contains(&(2, 2)));
1297    }
1298
1299    #[test]
1300    fn diff_clear() {
1301        let old = Buffer::new(5, 5);
1302        let mut new = Buffer::new(5, 5);
1303        new.set_raw(1, 1, Cell::from_char('X'));
1304
1305        let mut diff = BufferDiff::compute(&old, &new);
1306        assert_eq!(diff.len(), 1);
1307
1308        diff.clear();
1309        assert!(diff.is_empty());
1310    }
1311
1312    #[test]
1313    fn with_capacity() {
1314        let diff = BufferDiff::with_capacity(100);
1315        assert!(diff.is_empty());
1316    }
1317
1318    #[test]
1319    fn full_buffer_change() {
1320        let old = Buffer::new(5, 5);
1321        let mut new = Buffer::new(5, 5);
1322
1323        // Change every cell
1324        for y in 0..5 {
1325            for x in 0..5 {
1326                new.set_raw(x, y, Cell::from_char('#'));
1327            }
1328        }
1329
1330        let diff = BufferDiff::compute(&old, &new);
1331        assert_eq!(diff.len(), 25);
1332
1333        // Should coalesce into 5 runs (one per row)
1334        let runs = diff.runs();
1335        assert_eq!(runs.len(), 5);
1336
1337        for (i, run) in runs.iter().enumerate() {
1338            assert_eq!(run.y, i as u16);
1339            assert_eq!(run.x0, 0);
1340            assert_eq!(run.x1, 4);
1341            assert_eq!(run.len(), 5);
1342        }
1343    }
1344
1345    #[test]
1346    fn row_major_order_preserved() {
1347        let old = Buffer::new(3, 3);
1348        let mut new = Buffer::new(3, 3);
1349
1350        // Set cells in non-row-major order
1351        new.set_raw(2, 2, Cell::from_char('C'));
1352        new.set_raw(0, 0, Cell::from_char('A'));
1353        new.set_raw(1, 1, Cell::from_char('B'));
1354
1355        let diff = BufferDiff::compute(&old, &new);
1356
1357        // Row-major scan should produce (0,0), (1,1), (2,2)
1358        let changes = diff.changes();
1359        assert_eq!(changes[0], (0, 0));
1360        assert_eq!(changes[1], (1, 1));
1361        assert_eq!(changes[2], (2, 2));
1362    }
1363
1364    #[test]
1365    fn blockwise_scan_preserves_sparse_row_changes() {
1366        let old = Buffer::new(64, 2);
1367        let mut new = old.clone();
1368
1369        new.set_raw(1, 0, Cell::from_char('A'));
1370        new.set_raw(33, 0, Cell::from_char('B'));
1371        new.set_raw(62, 1, Cell::from_char('C'));
1372
1373        let diff = BufferDiff::compute(&old, &new);
1374        assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1375    }
1376
1377    #[test]
1378    fn rows_with_no_changes_are_skipped() {
1379        let old = Buffer::new(4, 3);
1380        let mut new = old.clone();
1381
1382        new.set_raw(1, 1, Cell::from_char('X'));
1383        new.set_raw(3, 1, Cell::from_char('Y'));
1384
1385        let diff = BufferDiff::compute(&old, &new);
1386        assert_eq!(diff.len(), 2);
1387        assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1388    }
1389
1390    #[test]
1391    fn clear_retains_capacity_for_reuse() {
1392        let mut diff = BufferDiff::with_capacity(16);
1393        diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1394        let capacity = diff.changes.capacity();
1395
1396        diff.clear();
1397
1398        assert!(diff.is_empty());
1399        assert!(diff.changes.capacity() >= capacity);
1400    }
1401
1402    #[test]
1403    #[should_panic(expected = "buffer widths must match")]
1404    fn compute_panics_on_width_mismatch() {
1405        let old = Buffer::new(5, 5);
1406        let new = Buffer::new(4, 5);
1407        let _ = BufferDiff::compute(&old, &new);
1408    }
1409
1410    // =========================================================================
1411    // Block-based Row Scan Tests (bd-4kq0.1.2)
1412    // =========================================================================
1413
1414    #[test]
1415    fn block_scan_alignment_exact_block() {
1416        // Width = 4 (exactly one block, no remainder)
1417        let old = Buffer::new(4, 1);
1418        let mut new = Buffer::new(4, 1);
1419        new.set_raw(2, 0, Cell::from_char('X'));
1420
1421        let diff = BufferDiff::compute(&old, &new);
1422        assert_eq!(diff.len(), 1);
1423        assert_eq!(diff.changes(), &[(2, 0)]);
1424    }
1425
1426    #[test]
1427    fn block_scan_alignment_remainder() {
1428        // Width = 7 (one full block + 3 remainder)
1429        let old = Buffer::new(7, 1);
1430        let mut new = Buffer::new(7, 1);
1431        // Change in full block part
1432        new.set_raw(1, 0, Cell::from_char('A'));
1433        // Change in remainder part
1434        new.set_raw(5, 0, Cell::from_char('B'));
1435        new.set_raw(6, 0, Cell::from_char('C'));
1436
1437        let diff = BufferDiff::compute(&old, &new);
1438        assert_eq!(diff.len(), 3);
1439        assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1440    }
1441
1442    #[test]
1443    fn block_scan_single_cell_row() {
1444        // Width = 1 (pure remainder, no full blocks)
1445        let old = Buffer::new(1, 1);
1446        let mut new = Buffer::new(1, 1);
1447        new.set_raw(0, 0, Cell::from_char('X'));
1448
1449        let diff = BufferDiff::compute(&old, &new);
1450        assert_eq!(diff.len(), 1);
1451        assert_eq!(diff.changes(), &[(0, 0)]);
1452    }
1453
1454    #[test]
1455    fn block_scan_two_cell_row() {
1456        // Width = 2 (pure remainder)
1457        let old = Buffer::new(2, 1);
1458        let mut new = Buffer::new(2, 1);
1459        new.set_raw(0, 0, Cell::from_char('A'));
1460        new.set_raw(1, 0, Cell::from_char('B'));
1461
1462        let diff = BufferDiff::compute(&old, &new);
1463        assert_eq!(diff.len(), 2);
1464        assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
1465    }
1466
1467    #[test]
1468    fn block_scan_three_cell_row() {
1469        // Width = 3 (pure remainder)
1470        let old = Buffer::new(3, 1);
1471        let mut new = Buffer::new(3, 1);
1472        new.set_raw(2, 0, Cell::from_char('X'));
1473
1474        let diff = BufferDiff::compute(&old, &new);
1475        assert_eq!(diff.len(), 1);
1476        assert_eq!(diff.changes(), &[(2, 0)]);
1477    }
1478
1479    #[test]
1480    fn block_scan_multiple_blocks_sparse() {
1481        // Width = 80 (20 full blocks), changes scattered across blocks
1482        let old = Buffer::new(80, 1);
1483        let mut new = Buffer::new(80, 1);
1484
1485        // One change per block in every other block
1486        for block in (0..20).step_by(2) {
1487            let x = (block * 4 + 1) as u16;
1488            new.set_raw(x, 0, Cell::from_char('X'));
1489        }
1490
1491        let diff = BufferDiff::compute(&old, &new);
1492        assert_eq!(diff.len(), 10);
1493        assert_eq!(
1494            diff.changes(),
1495            &[
1496                (1, 0),
1497                (9, 0),
1498                (17, 0),
1499                (25, 0),
1500                (33, 0),
1501                (41, 0),
1502                (49, 0),
1503                (57, 0),
1504                (65, 0),
1505                (73, 0)
1506            ]
1507        );
1508    }
1509
1510    #[test]
1511    fn block_scan_full_block_unchanged_skip() {
1512        // Verify blocks with no changes are skipped efficiently
1513        let old = Buffer::new(20, 1);
1514        let mut new = Buffer::new(20, 1);
1515
1516        // Only change one cell in the last block
1517        new.set_raw(19, 0, Cell::from_char('Z'));
1518
1519        let diff = BufferDiff::compute(&old, &new);
1520        assert_eq!(diff.len(), 1);
1521        assert_eq!(diff.changes(), &[(19, 0)]);
1522    }
1523
1524    #[test]
1525    fn block_scan_wide_row_all_changed() {
1526        // All cells changed in a wide row
1527        let old = Buffer::new(120, 1);
1528        let mut new = Buffer::new(120, 1);
1529        for x in 0..120 {
1530            new.set_raw(x, 0, Cell::from_char('#'));
1531        }
1532
1533        let diff = BufferDiff::compute(&old, &new);
1534        assert_eq!(diff.len(), 120);
1535    }
1536
1537    #[test]
1538    fn perf_block_scan_vs_scalar_baseline() {
1539        // Verify that block scan works correctly on large buffers
1540        // and measure relative performance with structured diagnostics.
1541        use std::time::Instant;
1542
1543        let width = 200u16;
1544        let height = 50u16;
1545        let old = Buffer::new(width, height);
1546        let mut new = Buffer::new(width, height);
1547
1548        // ~10% cells changed
1549        for i in 0..1000 {
1550            let x = (i * 7 + 3) as u16 % width;
1551            let y = (i * 11 + 5) as u16 % height;
1552            let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
1553            new.set_raw(x, y, Cell::from_char(ch));
1554        }
1555
1556        let iterations = 1000u32;
1557        let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
1558            .ok()
1559            .and_then(|value| value.parse::<usize>().ok())
1560            .unwrap_or(50)
1561            .clamp(1, iterations as usize);
1562        let iters_per_sample = (iterations / samples as u32).max(1) as u64;
1563
1564        let mut times_us = Vec::with_capacity(samples);
1565        let mut last_checksum = 0u64;
1566
1567        for _ in 0..samples {
1568            let start = Instant::now();
1569            for _ in 0..iters_per_sample {
1570                let diff = BufferDiff::compute(&old, &new);
1571                assert!(!diff.is_empty());
1572                last_checksum = fnv1a_hash(diff.changes());
1573            }
1574            let elapsed = start.elapsed();
1575            let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
1576            times_us.push(per_iter);
1577        }
1578
1579        times_us.sort_unstable();
1580        let len = times_us.len();
1581        let p50 = times_us[len / 2];
1582        let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1583        let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1584        let mean = times_us
1585            .iter()
1586            .copied()
1587            .map(|value| value as f64)
1588            .sum::<f64>()
1589            / len as f64;
1590        let variance = times_us
1591            .iter()
1592            .map(|value| {
1593                let delta = *value as f64 - mean;
1594                delta * delta
1595            })
1596            .sum::<f64>()
1597            / len as f64;
1598
1599        // JSONL log line for perf diagnostics (captured by --nocapture/CI artifacts).
1600        eprintln!(
1601            "{{\"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}\"}}",
1602            width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
1603        );
1604
1605        #[allow(unexpected_cfgs)]
1606        let budget_us = if std::env::var("LLVM_PROFILE_FILE").is_ok()
1607            || std::env::var("CARGO_LLVM_COV").is_ok()
1608        {
1609            3_000u64
1610        } else {
1611            500u64
1612        };
1613        assert!(
1614            p95 <= budget_us,
1615            "Diff too slow: p95={p95}µs (budget {budget_us}µs) for {width}x{height}"
1616        );
1617    }
1618    // =========================================================================
1619    // Run Coalescing Invariants (bd-4kq0.1.3)
1620    // =========================================================================
1621
1622    #[test]
1623    fn unit_run_coalescing_invariants() {
1624        // Verify runs preserve order, coverage, and contiguity for a
1625        // complex multi-row change pattern.
1626        let old = Buffer::new(80, 24);
1627        let mut new = Buffer::new(80, 24);
1628
1629        // Row 0: two separate runs (0-2) and (10-12)
1630        for x in 0..=2 {
1631            new.set_raw(x, 0, Cell::from_char('A'));
1632        }
1633        for x in 10..=12 {
1634            new.set_raw(x, 0, Cell::from_char('B'));
1635        }
1636        // Row 5: single run (40-45)
1637        for x in 40..=45 {
1638            new.set_raw(x, 5, Cell::from_char('C'));
1639        }
1640        // Row 23: single cell at end
1641        new.set_raw(79, 23, Cell::from_char('Z'));
1642
1643        let diff = BufferDiff::compute(&old, &new);
1644        let runs = diff.runs();
1645
1646        // Invariant 1: runs are sorted by (y, x0)
1647        for w in runs.windows(2) {
1648            assert!(
1649                (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
1650                "runs must be sorted: {:?} should precede {:?}",
1651                w[0],
1652                w[1]
1653            );
1654        }
1655
1656        // Invariant 2: total cells in runs == diff.len()
1657        let total_cells: usize = runs.iter().map(|r| r.len() as usize).sum();
1658        assert_eq!(
1659            total_cells,
1660            diff.len(),
1661            "runs must cover all changes exactly"
1662        );
1663
1664        // Invariant 3: no two runs on the same row are adjacent (should have merged)
1665        for w in runs.windows(2) {
1666            if w[0].y == w[1].y {
1667                assert!(
1668                    w[1].x0 > w[0].x1.saturating_add(1),
1669                    "adjacent runs on same row should be merged: {:?} and {:?}",
1670                    w[0],
1671                    w[1]
1672                );
1673            }
1674        }
1675
1676        // Invariant 4: expected structure
1677        assert_eq!(runs.len(), 4);
1678        assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
1679        assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
1680        assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
1681        assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
1682    }
1683
1684    // =========================================================================
1685    // Golden Output Fixtures (bd-4kq0.1.3)
1686    // =========================================================================
1687
1688    /// FNV-1a hash for deterministic checksums (no external dependency).
1689    fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
1690        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1691        for &(x, y) in data {
1692            for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
1693                hash ^= *byte as u64;
1694                hash = hash.wrapping_mul(0x0100_0000_01b3);
1695            }
1696        }
1697        hash
1698    }
1699
1700    /// Build a canonical "dashboard-like" scene: header row, status bar,
1701    /// scattered content cells.
1702    fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
1703        let mut buf = Buffer::new(width, height);
1704        let mut rng = seed;
1705
1706        // Header row: all cells set
1707        for x in 0..width {
1708            buf.set_raw(x, 0, Cell::from_char('='));
1709        }
1710
1711        // Status bar (last row)
1712        for x in 0..width {
1713            buf.set_raw(x, height - 1, Cell::from_char('-'));
1714        }
1715
1716        // Scattered content using simple LCG
1717        let count = (width as u64 * height as u64 / 10).max(5);
1718        for _ in 0..count {
1719            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1720            let x = ((rng >> 16) as u16) % width;
1721            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1722            let y = ((rng >> 16) as u16) % height;
1723            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1724            let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
1725            buf.set_raw(x, y, Cell::from_char(ch));
1726        }
1727
1728        buf
1729    }
1730
1731    #[test]
1732    fn golden_diff_80x24() {
1733        let old = Buffer::new(80, 24);
1734        let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
1735
1736        let diff = BufferDiff::compute(&old, &new);
1737        let checksum = fnv1a_hash(diff.changes());
1738
1739        // Verify determinism: same inputs → same output
1740        let diff2 = BufferDiff::compute(&old, &new);
1741        assert_eq!(
1742            fnv1a_hash(diff2.changes()),
1743            checksum,
1744            "diff must be deterministic"
1745        );
1746
1747        // Sanity: scene has header + status + scattered content
1748        assert!(
1749            diff.len() >= 160,
1750            "80x24 golden scene should have at least 160 changes (header+status), got {}",
1751            diff.len()
1752        );
1753
1754        let runs = diff.runs();
1755        // Header and status rows should each be one run
1756        assert_eq!(runs[0].y, 0, "first run should be header row");
1757        assert_eq!(runs[0].x0, 0);
1758        assert_eq!(runs[0].x1, 79);
1759        assert!(
1760            runs.last().unwrap().y == 23,
1761            "last row should contain status bar"
1762        );
1763    }
1764
1765    #[test]
1766    fn golden_diff_120x40() {
1767        let old = Buffer::new(120, 40);
1768        let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
1769
1770        let diff = BufferDiff::compute(&old, &new);
1771        let checksum = fnv1a_hash(diff.changes());
1772
1773        // Determinism
1774        let diff2 = BufferDiff::compute(&old, &new);
1775        assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1776
1777        // Sanity
1778        assert!(
1779            diff.len() >= 240,
1780            "120x40 golden scene should have >=240 changes, got {}",
1781            diff.len()
1782        );
1783
1784        // Dirty diff must match
1785        let dirty = BufferDiff::compute_dirty(&old, &new);
1786        assert_eq!(
1787            fnv1a_hash(dirty.changes()),
1788            checksum,
1789            "dirty diff must produce identical changes"
1790        );
1791    }
1792
1793    #[test]
1794    fn golden_sparse_update() {
1795        // Start from a populated scene, apply a small update
1796        let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
1797        let mut new = old.clone();
1798
1799        // Apply 5 deterministic changes
1800        new.set_raw(10, 5, Cell::from_char('!'));
1801        new.set_raw(11, 5, Cell::from_char('@'));
1802        new.set_raw(40, 12, Cell::from_char('#'));
1803        new.set_raw(70, 20, Cell::from_char('$'));
1804        new.set_raw(0, 23, Cell::from_char('%'));
1805
1806        let diff = BufferDiff::compute(&old, &new);
1807        let checksum = fnv1a_hash(diff.changes());
1808
1809        // Determinism
1810        let diff2 = BufferDiff::compute(&old, &new);
1811        assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1812
1813        // Exactly 5 changes (or fewer if some cells happened to already have that value)
1814        assert!(
1815            diff.len() <= 5,
1816            "sparse update should have <=5 changes, got {}",
1817            diff.len()
1818        );
1819        assert!(
1820            diff.len() >= 3,
1821            "sparse update should have >=3 changes, got {}",
1822            diff.len()
1823        );
1824    }
1825
1826    // =========================================================================
1827    // E2E Random Scene Replay (bd-4kq0.1.3)
1828    // =========================================================================
1829
1830    #[test]
1831    fn e2e_random_scene_replay() {
1832        // 10 frames of seeded scene evolution, verify checksums are
1833        // deterministic across replays and dirty/full paths agree.
1834        let width = 80u16;
1835        let height = 24u16;
1836        let base_seed: u64 = 0x5C3E_E3E1_A442u64;
1837
1838        let mut checksums = Vec::new();
1839
1840        for frame in 0..10u64 {
1841            let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1842            let old = build_golden_scene(width, height, seed);
1843            let new = build_golden_scene(width, height, seed.wrapping_add(1));
1844
1845            let diff = BufferDiff::compute(&old, &new);
1846            let dirty_diff = BufferDiff::compute_dirty(&old, &new);
1847
1848            // Full and dirty must agree
1849            assert_eq!(
1850                diff.changes(),
1851                dirty_diff.changes(),
1852                "frame {frame}: dirty diff must match full diff"
1853            );
1854
1855            checksums.push(fnv1a_hash(diff.changes()));
1856        }
1857
1858        // Replay: same seeds must produce identical checksums
1859        for frame in 0..10u64 {
1860            let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1861            let old = build_golden_scene(width, height, seed);
1862            let new = build_golden_scene(width, height, seed.wrapping_add(1));
1863
1864            let diff = BufferDiff::compute(&old, &new);
1865            assert_eq!(
1866                fnv1a_hash(diff.changes()),
1867                checksums[frame as usize],
1868                "frame {frame}: checksum mismatch on replay"
1869            );
1870        }
1871    }
1872
1873    // =========================================================================
1874    // Perf Microbench with JSONL (bd-4kq0.1.3)
1875    // =========================================================================
1876
1877    #[test]
1878    fn perf_diff_microbench() {
1879        use std::time::Instant;
1880
1881        let scenarios: &[(u16, u16, &str, u64)] = &[
1882            (80, 24, "full_frame", 0xBE4C_0001u64),
1883            (80, 24, "sparse_update", 0xBE4C_0002u64),
1884            (120, 40, "full_frame", 0xBE4C_0003u64),
1885            (120, 40, "sparse_update", 0xBE4C_0004u64),
1886        ];
1887
1888        let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1889            .ok()
1890            .and_then(|value| value.parse::<u32>().ok())
1891            .unwrap_or(50u32);
1892
1893        for &(width, height, scene_type, seed) in scenarios {
1894            let old = Buffer::new(width, height);
1895            let new = match scene_type {
1896                "full_frame" => build_golden_scene(width, height, seed),
1897                "sparse_update" => {
1898                    let mut buf = old.clone();
1899                    buf.set_raw(10, 5, Cell::from_char('!'));
1900                    buf.set_raw(40, 12, Cell::from_char('#'));
1901                    buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
1902                    buf
1903                }
1904                _ => unreachable!(),
1905            };
1906
1907            let mut times_us = Vec::with_capacity(iterations as usize);
1908            let mut last_changes = 0usize;
1909            let mut last_runs = 0usize;
1910            let mut last_checksum = 0u64;
1911
1912            for _ in 0..iterations {
1913                let start = Instant::now();
1914                let diff = BufferDiff::compute(&old, &new);
1915                let runs = diff.runs();
1916                let elapsed = start.elapsed();
1917
1918                last_changes = diff.len();
1919                last_runs = runs.len();
1920                last_checksum = fnv1a_hash(diff.changes());
1921                times_us.push(elapsed.as_micros() as u64);
1922            }
1923
1924            times_us.sort();
1925            let len = times_us.len();
1926            let p50 = times_us[len / 2];
1927            let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1928            let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1929
1930            // JSONL log line (captured by --nocapture or CI artifact)
1931            eprintln!(
1932                "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
1933                seed,
1934                width,
1935                height,
1936                scene_type,
1937                last_changes,
1938                last_runs,
1939                p50,
1940                p95,
1941                p99,
1942                last_checksum
1943            );
1944
1945            // Budget: full 80x24 diff should be < 100µs at p95
1946            // Budget: full 120x40 diff should be < 200µs at p95
1947            let budget_us = match (width, height) {
1948                (80, 24) => 500,   // generous for CI variance
1949                (120, 40) => 1000, // generous for CI variance
1950                _ => 2000,
1951            };
1952
1953            // Checksum must be identical across all iterations (determinism)
1954            for _ in 0..3 {
1955                let diff = BufferDiff::compute(&old, &new);
1956                assert_eq!(
1957                    fnv1a_hash(diff.changes()),
1958                    last_checksum,
1959                    "diff must be deterministic"
1960                );
1961            }
1962
1963            // Soft budget assertion (warn but don't fail on slow CI)
1964            if p95 > budget_us {
1965                eprintln!(
1966                    "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
1967                );
1968            }
1969        }
1970    }
1971
1972    // =========================================================================
1973    // Dirty vs Full Diff Regression Gate (bd-3e1t.1.6)
1974    // =========================================================================
1975
1976    #[test]
1977    fn perf_dirty_diff_large_screen_regression() {
1978        use std::time::Instant;
1979
1980        let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1981            .ok()
1982            .and_then(|value| value.parse::<u32>().ok())
1983            .unwrap_or(50u32);
1984
1985        let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
1986            .ok()
1987            .and_then(|value| value.parse::<f64>().ok())
1988            .unwrap_or(2.0);
1989
1990        let cases: &[(u16, u16, &str, f64)] = &[
1991            (200, 60, "sparse_5pct", 5.0),
1992            (240, 80, "sparse_5pct", 5.0),
1993            (200, 60, "single_row", 0.0),
1994            (240, 80, "single_row", 0.0),
1995        ];
1996
1997        for &(width, height, pattern, pct) in cases {
1998            let old = Buffer::new(width, height);
1999            let mut new = old.clone();
2000
2001            if pattern == "single_row" {
2002                for x in 0..width {
2003                    new.set_raw(x, 0, Cell::from_char('X'));
2004                }
2005            } else {
2006                let total = width as usize * height as usize;
2007                let to_change = ((total as f64) * pct / 100.0) as usize;
2008                for i in 0..to_change {
2009                    let x = (i * 7 + 3) as u16 % width;
2010                    let y = (i * 11 + 5) as u16 % height;
2011                    let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2012                    new.set_raw(x, y, Cell::from_char(ch));
2013                }
2014            }
2015
2016            // Sanity: dirty and full diffs must agree.
2017            let full = BufferDiff::compute(&old, &new);
2018            let dirty = BufferDiff::compute_dirty(&old, &new);
2019            let change_count = full.len();
2020            let dirty_rows = new.dirty_row_count();
2021            assert_eq!(
2022                full.changes(),
2023                dirty.changes(),
2024                "dirty diff must match full diff for {width}x{height} {pattern}"
2025            );
2026
2027            let mut full_times = Vec::with_capacity(iterations as usize);
2028            let mut dirty_times = Vec::with_capacity(iterations as usize);
2029
2030            for _ in 0..iterations {
2031                let start = Instant::now();
2032                let diff = BufferDiff::compute(&old, &new);
2033                std::hint::black_box(diff.len());
2034                full_times.push(start.elapsed().as_micros() as u64);
2035
2036                let start = Instant::now();
2037                let diff = BufferDiff::compute_dirty(&old, &new);
2038                std::hint::black_box(diff.len());
2039                dirty_times.push(start.elapsed().as_micros() as u64);
2040            }
2041
2042            full_times.sort();
2043            dirty_times.sort();
2044
2045            let len = full_times.len();
2046            let p50_idx = len / 2;
2047            let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2048
2049            let full_p50 = full_times[p50_idx];
2050            let full_p95 = full_times[p95_idx];
2051            let dirty_p50 = dirty_times[p50_idx];
2052            let dirty_p95 = dirty_times[p95_idx];
2053
2054            let denom = full_p50.max(1) as f64;
2055            let ratio = dirty_p50 as f64 / denom;
2056
2057            eprintln!(
2058                "{{\"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\":{}}}",
2059                width,
2060                height,
2061                pattern,
2062                iterations,
2063                change_count,
2064                dirty_rows,
2065                full_p50,
2066                full_p95,
2067                dirty_p50,
2068                dirty_p95,
2069                ratio,
2070                max_slowdown
2071            );
2072
2073            assert!(
2074                ratio <= max_slowdown,
2075                "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn tile_diff_matches_compute_for_sparse_tiles() {
2082        let width = 200;
2083        let height = 60;
2084        let old = Buffer::new(width, height);
2085        let mut new = old.clone();
2086
2087        new.clear_dirty();
2088        for x in 0..10u16 {
2089            new.set_raw(x, 0, Cell::from_char('X'));
2090        }
2091
2092        let full = BufferDiff::compute(&old, &new);
2093        let dirty = BufferDiff::compute_dirty(&old, &new);
2094
2095        assert_eq!(full.changes(), dirty.changes());
2096        let stats = dirty
2097            .last_tile_stats()
2098            .expect("tile stats should be recorded");
2099        assert!(
2100            stats.fallback.is_none(),
2101            "tile path should be used for sparse tiles"
2102        );
2103    }
2104
2105    fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2106        let mut buffer = Buffer::new(width, height);
2107        buffer.clear_dirty();
2108        for &(x, y, ch) in changes {
2109            buffer.set_raw(x, y, Cell::from_char(ch));
2110        }
2111        buffer
2112    }
2113
2114    fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2115        let mut diff = BufferDiff::new();
2116        *diff.tile_config_mut() = config;
2117        diff.compute_dirty_into(old, new);
2118        diff.last_tile_stats()
2119            .expect("tile stats should be recorded")
2120    }
2121
2122    #[test]
2123    fn tile_fallback_disabled_when_config_off() {
2124        let width = 64;
2125        let height = 32;
2126        let old = Buffer::new(width, height);
2127        let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2128
2129        let config = TileDiffConfig {
2130            enabled: false,
2131            min_cells_for_tiles: 0,
2132            dense_cell_ratio: 1.1,
2133            dense_tile_ratio: 1.1,
2134            max_tiles: usize::MAX,
2135            ..Default::default()
2136        };
2137
2138        let stats = tile_stats_for_config(&old, &new, config);
2139        assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2140    }
2141
2142    #[test]
2143    fn tile_fallback_small_screen_when_below_threshold() {
2144        let width = 64;
2145        let height = 32;
2146        let old = Buffer::new(width, height);
2147        let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2148
2149        let config = TileDiffConfig {
2150            enabled: true,
2151            min_cells_for_tiles: width as usize * height as usize + 1,
2152            dense_cell_ratio: 1.1,
2153            dense_tile_ratio: 1.1,
2154            max_tiles: usize::MAX,
2155            ..Default::default()
2156        };
2157
2158        let stats = tile_stats_for_config(&old, &new, config);
2159        assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2160    }
2161
2162    #[test]
2163    fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2164        let width = 64;
2165        let height = 64;
2166        let old = Buffer::new(width, height);
2167        let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2168
2169        let config = TileDiffConfig {
2170            enabled: true,
2171            tile_w: 8,
2172            tile_h: 8,
2173            min_cells_for_tiles: 0,
2174            dense_cell_ratio: 1.1,
2175            dense_tile_ratio: 1.1,
2176            max_tiles: 4,
2177        };
2178
2179        let stats = tile_stats_for_config(&old, &new, config);
2180        assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2181    }
2182
2183    #[test]
2184    fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2185        let width = 64;
2186        let height = 32;
2187        let old = Buffer::new(width, height);
2188        let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2189
2190        let config = TileDiffConfig {
2191            enabled: true,
2192            tile_w: 8,
2193            tile_h: 8,
2194            min_cells_for_tiles: 0,
2195            dense_cell_ratio: 1.1,
2196            dense_tile_ratio: 0.05,
2197            max_tiles: usize::MAX / 4,
2198        };
2199
2200        let stats = tile_stats_for_config(&old, &new, config);
2201        assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2202    }
2203
2204    fn lcg_next(state: &mut u64) -> u64 {
2205        *state = state
2206            .wrapping_mul(6364136223846793005)
2207            .wrapping_add(1442695040888963407);
2208        *state
2209    }
2210
2211    fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2212        let width = buf.width().max(1) as u64;
2213        let height = buf.height().max(1) as u64;
2214        let mut state = seed;
2215        for i in 0..count {
2216            let v = lcg_next(&mut state);
2217            let x = (v % width) as u16;
2218            let y = ((v >> 32) % height) as u16;
2219            let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2220            buf.set_raw(x, y, Cell::from_char(ch));
2221        }
2222    }
2223
2224    fn tile_diag(stats: &TileDiffStats) -> String {
2225        let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2226        format!(
2227            "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2228            stats.dirty_tiles,
2229            stats.skipped_tiles,
2230            stats.dirty_cells,
2231            stats.dirty_tile_ratio,
2232            stats.dirty_cell_ratio,
2233            stats.scanned_tiles,
2234            stats.fallback
2235        )
2236    }
2237
2238    fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2239        let mut diff = BufferDiff::new();
2240        {
2241            let config = diff.tile_config_mut();
2242            config.enabled = true;
2243            config.tile_w = 8;
2244            config.tile_h = 8;
2245            config.min_cells_for_tiles = 0;
2246            config.dense_cell_ratio = 1.1;
2247            config.dense_tile_ratio = 1.1;
2248            config.max_tiles = usize::MAX / 4;
2249        }
2250        diff.compute_dirty_into(old, new);
2251        let stats = diff
2252            .last_tile_stats()
2253            .expect("tile stats should be recorded");
2254        (diff, stats)
2255    }
2256
2257    fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2258        let full = BufferDiff::compute(old, new);
2259        let (dirty, stats) = diff_with_forced_tiles(old, new);
2260        let diag = tile_diag(&stats);
2261        assert!(
2262            stats.fallback.is_none(),
2263            "tile diff fallback ({label}) {w}x{h}: {diag}",
2264            w = old.width(),
2265            h = old.height()
2266        );
2267        assert!(
2268            full.changes() == dirty.changes(),
2269            "tile diff mismatch ({label}) {w}x{h}: {diag}",
2270            w = old.width(),
2271            h = old.height()
2272        );
2273    }
2274
2275    #[test]
2276    fn tile_diff_equivalence_small_and_odd_sizes() {
2277        let cases: &[(u16, u16, usize)] = &[
2278            (1, 1, 1),
2279            (2, 1, 1),
2280            (1, 2, 1),
2281            (5, 3, 4),
2282            (7, 13, 12),
2283            (15, 9, 20),
2284            (31, 5, 12),
2285        ];
2286
2287        for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2288            let old = Buffer::new(width, height);
2289            let mut new = old.clone();
2290            new.clear_dirty();
2291            apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2292            assert_tile_diff_equivalence(&old, &new, "small_odd");
2293        }
2294    }
2295
2296    #[test]
2297    fn tile_diff_equivalence_large_sparse_random() {
2298        let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2299        for (idx, &(width, height)) in cases.iter().enumerate() {
2300            let old = Buffer::new(width, height);
2301            let mut new = old.clone();
2302            new.clear_dirty();
2303            let total = width as usize * height as usize;
2304            let changes = (total / 100).max(1);
2305            apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2306            assert_tile_diff_equivalence(&old, &new, "large_sparse");
2307        }
2308    }
2309
2310    #[test]
2311    fn tile_diff_equivalence_row_and_full_buffer() {
2312        let width = 200u16;
2313        let height = 60u16;
2314        let old = Buffer::new(width, height);
2315
2316        let mut row = old.clone();
2317        row.clear_dirty();
2318        for x in 0..width {
2319            row.set_raw(x, 0, Cell::from_char('R'));
2320        }
2321        assert_tile_diff_equivalence(&old, &row, "single_row");
2322
2323        let mut full = old.clone();
2324        full.clear_dirty();
2325        for y in 0..height {
2326            for x in 0..width {
2327                full.set_raw(x, y, Cell::from_char('F'));
2328            }
2329        }
2330        assert_tile_diff_equivalence(&old, &full, "full_buffer");
2331    }
2332}
2333
2334#[cfg(test)]
2335mod proptests {
2336    use super::*;
2337    use crate::cell::Cell;
2338    use ftui_core::geometry::Rect;
2339    use proptest::prelude::*;
2340
2341    // Property: Applying diff changes to old buffer produces new buffer.
2342    #[test]
2343    fn diff_apply_produces_target() {
2344        proptest::proptest!(|(
2345            width in 5u16..50,
2346            height in 5u16..30,
2347            num_changes in 0usize..200,
2348        )| {
2349            // Create old buffer (all spaces)
2350            let old = Buffer::new(width, height);
2351
2352            // Create new buffer by making random changes
2353            let mut new = old.clone();
2354            for i in 0..num_changes {
2355                let x = (i * 7 + 3) as u16 % width;
2356                let y = (i * 11 + 5) as u16 % height;
2357                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2358                new.set_raw(x, y, Cell::from_char(ch));
2359            }
2360
2361            // Compute diff
2362            let diff = BufferDiff::compute(&old, &new);
2363
2364            // Apply diff to old should produce new
2365            let mut result = old.clone();
2366            for (x, y) in diff.iter() {
2367                let cell = *new.get_unchecked(x, y);
2368                result.set_raw(x, y, cell);
2369            }
2370
2371            // Verify buffers match
2372            for y in 0..height {
2373                for x in 0..width {
2374                    let result_cell = result.get_unchecked(x, y);
2375                    let new_cell = new.get_unchecked(x, y);
2376                    prop_assert!(
2377                        result_cell.bits_eq(new_cell),
2378                        "Mismatch at ({}, {})",
2379                        x,
2380                        y
2381                    );
2382                }
2383            }
2384        });
2385    }
2386
2387    // Property: Diff is empty when buffers are identical.
2388    #[test]
2389    fn identical_buffers_empty_diff() {
2390        proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
2391            let buf = Buffer::new(width, height);
2392            let diff = BufferDiff::compute(&buf, &buf);
2393            prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
2394        });
2395    }
2396
2397    // Property: Every change in diff corresponds to an actual difference.
2398    #[test]
2399    fn diff_contains_only_real_changes() {
2400        proptest::proptest!(|(
2401            width in 5u16..50,
2402            height in 5u16..30,
2403            num_changes in 0usize..100,
2404        )| {
2405            let old = Buffer::new(width, height);
2406            let mut new = old.clone();
2407
2408            for i in 0..num_changes {
2409                let x = (i * 7 + 3) as u16 % width;
2410                let y = (i * 11 + 5) as u16 % height;
2411                new.set_raw(x, y, Cell::from_char('X'));
2412            }
2413
2414            let diff = BufferDiff::compute(&old, &new);
2415
2416            // Every change position should actually differ
2417            for (x, y) in diff.iter() {
2418                let old_cell = old.get_unchecked(x, y);
2419                let new_cell = new.get_unchecked(x, y);
2420                prop_assert!(
2421                    !old_cell.bits_eq(new_cell),
2422                    "Diff includes unchanged cell at ({}, {})",
2423                    x,
2424                    y
2425                );
2426            }
2427        });
2428    }
2429
2430    // Property: Runs correctly coalesce adjacent changes.
2431    #[test]
2432    fn runs_are_contiguous() {
2433        proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
2434            let old = Buffer::new(width, height);
2435            let mut new = old.clone();
2436
2437            // Create some horizontal runs
2438            for y in 0..height.min(5) {
2439                for x in 0..width.min(10) {
2440                    new.set_raw(x, y, Cell::from_char('#'));
2441                }
2442            }
2443
2444            let diff = BufferDiff::compute(&old, &new);
2445            let runs = diff.runs();
2446
2447            // Verify each run is contiguous
2448            for run in runs {
2449                prop_assert!(run.x1 >= run.x0, "Run has invalid range");
2450                prop_assert!(!run.is_empty(), "Run should not be empty");
2451
2452                // Verify all cells in run are actually changed
2453                for x in run.x0..=run.x1 {
2454                    let old_cell = old.get_unchecked(x, run.y);
2455                    let new_cell = new.get_unchecked(x, run.y);
2456                    prop_assert!(
2457                        !old_cell.bits_eq(new_cell),
2458                        "Run includes unchanged cell at ({}, {})",
2459                        x,
2460                        run.y
2461                    );
2462                }
2463            }
2464        });
2465    }
2466
2467    // Property: Runs cover all changes exactly once.
2468    #[test]
2469    fn runs_cover_all_changes() {
2470        proptest::proptest!(|(
2471            width in 10u16..60,
2472            height in 5u16..30,
2473            num_changes in 1usize..100,
2474        )| {
2475            let old = Buffer::new(width, height);
2476            let mut new = old.clone();
2477
2478            for i in 0..num_changes {
2479                let x = (i * 13 + 7) as u16 % width;
2480                let y = (i * 17 + 3) as u16 % height;
2481                new.set_raw(x, y, Cell::from_char('X'));
2482            }
2483
2484            let diff = BufferDiff::compute(&old, &new);
2485            let runs = diff.runs();
2486
2487            // Count cells covered by runs
2488            let mut run_cells: std::collections::HashSet<(u16, u16)> =
2489                std::collections::HashSet::new();
2490            for run in &runs {
2491                for x in run.x0..=run.x1 {
2492                    let was_new = run_cells.insert((x, run.y));
2493                    prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
2494                }
2495            }
2496
2497            // Verify runs cover exactly the changes
2498            for (x, y) in diff.iter() {
2499                prop_assert!(
2500                    run_cells.contains(&(x, y)),
2501                    "Change at ({}, {}) not covered by runs",
2502                    x,
2503                    y
2504                );
2505            }
2506
2507            prop_assert_eq!(
2508                run_cells.len(),
2509                diff.len(),
2510                "Run cell count should match diff change count"
2511            );
2512        });
2513    }
2514
2515    // Property (bd-4kq0.1.2): Block-based scan matches scalar scan
2516    // for random row widths and change patterns. This verifies the
2517    // block/remainder handling is correct for all alignment cases.
2518    #[test]
2519    fn block_scan_matches_scalar() {
2520        proptest::proptest!(|(
2521            width in 1u16..200,
2522            height in 1u16..20,
2523            num_changes in 0usize..200,
2524        )| {
2525            use crate::cell::PackedRgba;
2526
2527            let old = Buffer::new(width, height);
2528            let mut new = Buffer::new(width, height);
2529
2530            for i in 0..num_changes {
2531                let x = (i * 13 + 7) as u16 % width;
2532                let y = (i * 17 + 3) as u16 % height;
2533                let fg = PackedRgba::rgb(
2534                    ((i * 31) % 256) as u8,
2535                    ((i * 47) % 256) as u8,
2536                    ((i * 71) % 256) as u8,
2537                );
2538                new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
2539            }
2540
2541            let diff = BufferDiff::compute(&old, &new);
2542
2543            // Verify against manual scalar scan
2544            let mut scalar_changes = Vec::new();
2545            for y in 0..height {
2546                for x in 0..width {
2547                    let old_cell = old.get_unchecked(x, y);
2548                    let new_cell = new.get_unchecked(x, y);
2549                    if !old_cell.bits_eq(new_cell) {
2550                        scalar_changes.push((x, y));
2551                    }
2552                }
2553            }
2554
2555            prop_assert_eq!(
2556                diff.changes(),
2557                &scalar_changes[..],
2558                "Block scan should match scalar scan"
2559            );
2560        });
2561    }
2562
2563    // ========== Diff Equivalence: dirty+block vs full scan (bd-4kq0.1.3) ==========
2564
2565    // Property: compute_dirty with all rows dirty matches compute exactly.
2566    // This verifies the block-scan + dirty-row path is semantically
2567    // equivalent to the full scan for random buffers.
2568    #[test]
2569    fn property_diff_equivalence() {
2570        proptest::proptest!(|(
2571            width in 1u16..120,
2572            height in 1u16..40,
2573            num_changes in 0usize..300,
2574        )| {
2575            let old = Buffer::new(width, height);
2576            let mut new = Buffer::new(width, height);
2577
2578            // Apply deterministic pseudo-random changes
2579            for i in 0..num_changes {
2580                let x = (i * 13 + 7) as u16 % width;
2581                let y = (i * 17 + 3) as u16 % height;
2582                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2583                let fg = crate::cell::PackedRgba::rgb(
2584                    ((i * 31) % 256) as u8,
2585                    ((i * 47) % 256) as u8,
2586                    ((i * 71) % 256) as u8,
2587                );
2588                new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
2589            }
2590
2591            let full = BufferDiff::compute(&old, &new);
2592            let dirty = BufferDiff::compute_dirty(&old, &new);
2593
2594            prop_assert_eq!(
2595                full.changes(),
2596                dirty.changes(),
2597                "dirty diff must match full diff (width={}, height={}, changes={})",
2598                width,
2599                height,
2600                num_changes
2601            );
2602
2603            // Also verify run coalescing is identical
2604            let full_runs = full.runs();
2605            let dirty_runs = dirty.runs();
2606            prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
2607            for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
2608                prop_assert_eq!(fr, dr, "run mismatch");
2609            }
2610        });
2611    }
2612
2613    // Property: compute_dirty matches compute for random fill/set operations.
2614    // This exercises span merging and complex dirty patterns (bd-3e1t.6.4).
2615    #[test]
2616    fn property_diff_equivalence_complex_spans() {
2617        proptest::proptest!(|(
2618            width in 10u16..100,
2619            height in 10u16..50,
2620            ops in proptest::collection::vec(
2621                prop_oneof![
2622                    // Single cell set
2623                    (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
2624                    // Region fill (small rects)
2625                    (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
2626                ],
2627                1..50
2628            )
2629        )| {
2630            let old = Buffer::new(width, height);
2631            let mut new = Buffer::new(width, height);
2632
2633            // Clear dirty state on new so we start fresh tracking
2634            new.clear_dirty();
2635
2636            for (op_type, x, y, ch) in ops {
2637                let x = x % width;
2638                let y = y % height;
2639                let cell = Cell::from_char(ch);
2640
2641                match op_type {
2642                    0 => new.set(x, y, cell),
2643                    1 => {
2644                        // Random small rect
2645                        let w = ((x + 10).min(width) - x).max(1);
2646                        let h = ((y + 5).min(height) - y).max(1);
2647                        new.fill(Rect::new(x, y, w, h), cell);
2648                    }
2649                    _ => unreachable!(),
2650                }
2651            }
2652
2653            let full = BufferDiff::compute(&old, &new);
2654            let dirty = BufferDiff::compute_dirty(&old, &new);
2655
2656            prop_assert_eq!(
2657                full.changes(),
2658                dirty.changes(),
2659                "dirty diff (spans) must match full diff; {}",
2660                super::span_diagnostics(&new)
2661            );
2662        });
2663    }
2664
2665    // ========== Idempotence Property (bd-1rz0.6) ==========
2666
2667    // Property: Diff is idempotent - computing diff between identical buffers
2668    // produces empty diff, and applying diff twice has no additional effect.
2669    //
2670    // Invariant: For any buffers A and B:
2671    //   apply(apply(A, diff(A,B)), diff(A,B)) == apply(A, diff(A,B))
2672    #[test]
2673    fn diff_is_idempotent() {
2674        proptest::proptest!(|(
2675            width in 5u16..60,
2676            height in 5u16..30,
2677            num_changes in 0usize..100,
2678        )| {
2679            let mut buf_a = Buffer::new(width, height);
2680            let mut buf_b = Buffer::new(width, height);
2681
2682            // Make buf_b different from buf_a
2683            for i in 0..num_changes {
2684                let x = (i * 13 + 7) as u16 % width;
2685                let y = (i * 17 + 3) as u16 % height;
2686                buf_b.set_raw(x, y, Cell::from_char('X'));
2687            }
2688
2689            // Compute diff from A to B
2690            let diff = BufferDiff::compute(&buf_a, &buf_b);
2691
2692            // Apply diff to A once
2693            for (x, y) in diff.iter() {
2694                let cell = *buf_b.get_unchecked(x, y);
2695                buf_a.set_raw(x, y, cell);
2696            }
2697
2698            // Now buf_a should equal buf_b
2699            let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
2700            prop_assert!(
2701                diff_after_first.is_empty(),
2702                "After applying diff once, buffers should be identical (diff was {} changes)",
2703                diff_after_first.len()
2704            );
2705
2706            // Apply diff again (should be no-op since buffers are now equal)
2707            let before_second = buf_a.clone();
2708            for (x, y) in diff.iter() {
2709                let cell = *buf_b.get_unchecked(x, y);
2710                buf_a.set_raw(x, y, cell);
2711            }
2712
2713            // Verify no change from second application
2714            let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
2715            prop_assert!(
2716                diff_after_second.is_empty(),
2717                "Second diff application should be a no-op"
2718            );
2719        });
2720    }
2721
2722    // ========== No-Ghosting After Clear Property (bd-1rz0.6) ==========
2723
2724    // Property: After a full buffer clear (simulating resize), diffing
2725    // against a blank old buffer captures all content cells.
2726    //
2727    // This simulates the no-ghosting invariant: when terminal shrinks,
2728    // we present against a fresh blank buffer, ensuring no old content
2729    // persists. The key is that all non-blank cells in the new buffer
2730    // appear in the diff.
2731    //
2732    // Failure mode: If we diff against stale buffer state after resize,
2733    // some cells might be incorrectly marked as unchanged.
2734    #[test]
2735    fn no_ghosting_after_clear() {
2736        proptest::proptest!(|(
2737            width in 10u16..80,
2738            height in 5u16..30,
2739            num_content_cells in 1usize..200,
2740        )| {
2741            // Old buffer is blank (simulating post-resize cleared state)
2742            let old = Buffer::new(width, height);
2743
2744            // New buffer has content (the UI to render)
2745            let mut new = Buffer::new(width, height);
2746            let mut expected_changes = std::collections::HashSet::new();
2747
2748            for i in 0..num_content_cells {
2749                let x = (i * 13 + 7) as u16 % width;
2750                let y = (i * 17 + 3) as u16 % height;
2751                new.set_raw(x, y, Cell::from_char('#'));
2752                expected_changes.insert((x, y));
2753            }
2754
2755            let diff = BufferDiff::compute(&old, &new);
2756
2757            // Every non-blank cell should be in the diff
2758            // This ensures no "ghosting" - all visible content is explicitly rendered
2759            for (x, y) in expected_changes {
2760                let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
2761                prop_assert!(
2762                    in_diff,
2763                    "Content cell at ({}, {}) missing from diff - would ghost",
2764                    x,
2765                    y
2766                );
2767            }
2768
2769            // Also verify the diff doesn't include any extra cells
2770            for (x, y) in diff.iter() {
2771                let old_cell = old.get_unchecked(x, y);
2772                let new_cell = new.get_unchecked(x, y);
2773                prop_assert!(
2774                    !old_cell.bits_eq(new_cell),
2775                    "Diff includes unchanged cell at ({}, {})",
2776                    x,
2777                    y
2778                );
2779            }
2780        });
2781    }
2782
2783    // ========== Monotonicity Property (bd-1rz0.6) ==========
2784
2785    // Property: Diff changes are monotonically ordered (row-major).
2786    // This ensures deterministic iteration order for presentation.
2787    //
2788    // Invariant: For consecutive changes (x1,y1) and (x2,y2):
2789    //   y1 < y2 OR (y1 == y2 AND x1 < x2)
2790    #[test]
2791    fn diff_changes_are_monotonic() {
2792        proptest::proptest!(|(
2793            width in 10u16..80,
2794            height in 5u16..30,
2795            num_changes in 1usize..200,
2796        )| {
2797            let old = Buffer::new(width, height);
2798            let mut new = old.clone();
2799
2800            // Apply changes in random positions
2801            for i in 0..num_changes {
2802                let x = (i * 37 + 11) as u16 % width;
2803                let y = (i * 53 + 7) as u16 % height;
2804                new.set_raw(x, y, Cell::from_char('M'));
2805            }
2806
2807            let diff = BufferDiff::compute(&old, &new);
2808            let changes: Vec<_> = diff.iter().collect();
2809
2810            // Verify monotonic ordering
2811            for window in changes.windows(2) {
2812                let (x1, y1) = window[0];
2813                let (x2, y2) = window[1];
2814
2815                let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
2816                prop_assert!(
2817                    is_monotonic,
2818                    "Changes not monotonic: ({}, {}) should come before ({}, {})",
2819                    x1,
2820                    y1,
2821                    x2,
2822                    y2
2823                );
2824            }
2825        });
2826    }
2827}
2828
2829#[cfg(test)]
2830mod span_edge_cases {
2831    use super::*;
2832    use crate::cell::Cell;
2833    use proptest::prelude::*;
2834
2835    #[test]
2836    fn test_span_diff_u16_max_width() {
2837        // Test near u16::MAX limit (65535)
2838        let width = 65000;
2839        let height = 1;
2840        let old = Buffer::new(width, height);
2841        let mut new = Buffer::new(width, height);
2842
2843        // We must clear dirty because Buffer::new starts with all rows dirty
2844        new.clear_dirty();
2845
2846        // Set changes at start, middle, end
2847        new.set_raw(0, 0, Cell::from_char('A'));
2848        new.set_raw(32500, 0, Cell::from_char('B'));
2849        new.set_raw(64999, 0, Cell::from_char('C'));
2850
2851        let full = BufferDiff::compute(&old, &new);
2852        let dirty = BufferDiff::compute_dirty(&old, &new);
2853
2854        assert_eq!(full.changes(), dirty.changes());
2855        assert_eq!(full.len(), 3);
2856
2857        // Verify changes are what we expect
2858        let changes = full.changes();
2859        assert!(changes.contains(&(0, 0)));
2860        assert!(changes.contains(&(32500, 0)));
2861        assert!(changes.contains(&(64999, 0)));
2862    }
2863
2864    #[test]
2865    fn test_span_full_row_dirty_overflow() {
2866        let width = 1000;
2867        let height = 1;
2868        let old = Buffer::new(width, height);
2869        let mut new = Buffer::new(width, height);
2870        new.clear_dirty(); // All clean
2871
2872        // Create > 64 spans to force overflow
2873        // DIRTY_SPAN_MAX_SPANS_PER_ROW is 64
2874        for i in 0..70 {
2875            let x = (i * 10) as u16;
2876            new.set_raw(x, 0, Cell::from_char('X'));
2877        }
2878
2879        // Verify it overflowed
2880        let stats = new.dirty_span_stats();
2881        assert!(
2882            stats.rows_full_dirty > 0,
2883            "Should have overflowed to full row"
2884        );
2885        assert_eq!(
2886            stats.rows_with_spans, 0,
2887            "Should have cleared spans on overflow"
2888        );
2889
2890        let full = BufferDiff::compute(&old, &new);
2891        let dirty = BufferDiff::compute_dirty(&old, &new);
2892
2893        assert_eq!(full.changes(), dirty.changes());
2894        assert_eq!(full.len(), 70);
2895    }
2896
2897    #[test]
2898    fn test_span_diff_empty_rows() {
2899        let width = 100;
2900        let height = 10;
2901        let old = Buffer::new(width, height);
2902        let mut new = Buffer::new(width, height);
2903        new.clear_dirty(); // All clean
2904
2905        // No changes
2906        let dirty = BufferDiff::compute_dirty(&old, &new);
2907        assert!(dirty.is_empty());
2908    }
2909
2910    proptest! {
2911        #[test]
2912        fn property_span_diff_equivalence_large(
2913            width in 1000u16..5000,
2914            height in 10u16..50,
2915            changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
2916        ) {
2917            // Cap width/height to avoid OOM in test runner
2918            let w = width.min(2000);
2919            let h = height.min(50);
2920
2921            let old = Buffer::new(w, h);
2922            let mut new = Buffer::new(w, h);
2923            new.clear_dirty();
2924
2925            // Apply changes
2926            for (raw_x, raw_y) in changes {
2927                let x = raw_x % w;
2928                let y = raw_y % h;
2929                new.set_raw(x, y, Cell::from_char('Z'));
2930            }
2931
2932            let full = BufferDiff::compute(&old, &new);
2933            let dirty = BufferDiff::compute_dirty(&old, &new);
2934
2935            prop_assert_eq!(
2936                full.changes(),
2937                dirty.changes(),
2938                "Large buffer mismatch: w={}, h={}, {}",
2939                w,
2940                h,
2941                super::span_diagnostics(&new)
2942            );
2943        }
2944    }
2945}