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    fn is_coverage_run() -> bool {
1099        if let Ok(value) = std::env::var("FTUI_COVERAGE") {
1100            let value = value.to_ascii_lowercase();
1101            if matches!(value.as_str(), "1" | "true" | "yes") {
1102                return true;
1103            }
1104            if matches!(value.as_str(), "0" | "false" | "no") {
1105                return false;
1106            }
1107        }
1108        std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
1109    }
1110    use super::*;
1111    use crate::cell::{Cell, PackedRgba};
1112
1113    #[test]
1114    fn empty_diff_when_buffers_identical() {
1115        let buf1 = Buffer::new(10, 10);
1116        let buf2 = Buffer::new(10, 10);
1117        let diff = BufferDiff::compute(&buf1, &buf2);
1118
1119        assert!(diff.is_empty());
1120        assert_eq!(diff.len(), 0);
1121    }
1122
1123    #[test]
1124    fn full_diff_marks_all_cells() {
1125        let diff = BufferDiff::full(3, 2);
1126        assert_eq!(diff.len(), 6);
1127        assert_eq!(diff.changes()[0], (0, 0));
1128        assert_eq!(diff.changes()[5], (2, 1));
1129    }
1130
1131    #[test]
1132    fn single_cell_change_detected() {
1133        let old = Buffer::new(10, 10);
1134        let mut new = Buffer::new(10, 10);
1135
1136        new.set_raw(5, 5, Cell::from_char('X'));
1137        let diff = BufferDiff::compute(&old, &new);
1138
1139        assert_eq!(diff.len(), 1);
1140        assert_eq!(diff.changes(), &[(5, 5)]);
1141    }
1142
1143    #[test]
1144    fn dirty_row_false_positive_skipped() {
1145        let old = Buffer::new(8, 2);
1146        let mut new = old.clone();
1147
1148        // Clear initial dirty state to isolate this row.
1149        new.clear_dirty();
1150        // Mark row 0 dirty without changing content.
1151        new.set_raw(2, 0, Cell::default());
1152
1153        let diff = BufferDiff::compute_dirty(&old, &new);
1154        assert!(
1155            diff.is_empty(),
1156            "dirty row with no changes should be skipped"
1157        );
1158    }
1159
1160    #[test]
1161    fn multiple_scattered_changes_detected() {
1162        let old = Buffer::new(10, 10);
1163        let mut new = Buffer::new(10, 10);
1164
1165        new.set_raw(0, 0, Cell::from_char('A'));
1166        new.set_raw(9, 9, Cell::from_char('B'));
1167        new.set_raw(5, 3, Cell::from_char('C'));
1168
1169        let diff = BufferDiff::compute(&old, &new);
1170
1171        assert_eq!(diff.len(), 3);
1172        // Sorted by row-major order: (0,0), (5,3), (9,9)
1173        let changes = diff.changes();
1174        assert!(changes.contains(&(0, 0)));
1175        assert!(changes.contains(&(9, 9)));
1176        assert!(changes.contains(&(5, 3)));
1177    }
1178
1179    #[test]
1180    fn runs_coalesces_adjacent_cells() {
1181        let old = Buffer::new(10, 10);
1182        let mut new = Buffer::new(10, 10);
1183
1184        // Three adjacent cells on row 5
1185        new.set_raw(3, 5, Cell::from_char('A'));
1186        new.set_raw(4, 5, Cell::from_char('B'));
1187        new.set_raw(5, 5, Cell::from_char('C'));
1188
1189        let diff = BufferDiff::compute(&old, &new);
1190        let runs = diff.runs();
1191
1192        assert_eq!(runs.len(), 1);
1193        assert_eq!(runs[0].y, 5);
1194        assert_eq!(runs[0].x0, 3);
1195        assert_eq!(runs[0].x1, 5);
1196        assert_eq!(runs[0].len(), 3);
1197    }
1198
1199    #[test]
1200    fn runs_handles_gaps_correctly() {
1201        let old = Buffer::new(10, 10);
1202        let mut new = Buffer::new(10, 10);
1203
1204        // Two groups with a gap
1205        new.set_raw(0, 0, Cell::from_char('A'));
1206        new.set_raw(1, 0, Cell::from_char('B'));
1207        // gap at x=2
1208        new.set_raw(3, 0, Cell::from_char('C'));
1209        new.set_raw(4, 0, Cell::from_char('D'));
1210
1211        let diff = BufferDiff::compute(&old, &new);
1212        let runs = diff.runs();
1213
1214        assert_eq!(runs.len(), 2);
1215
1216        assert_eq!(runs[0].y, 0);
1217        assert_eq!(runs[0].x0, 0);
1218        assert_eq!(runs[0].x1, 1);
1219
1220        assert_eq!(runs[1].y, 0);
1221        assert_eq!(runs[1].x0, 3);
1222        assert_eq!(runs[1].x1, 4);
1223    }
1224
1225    #[test]
1226    fn runs_handles_max_column_without_overflow() {
1227        let mut diff = BufferDiff::new();
1228        diff.changes = vec![(u16::MAX, 0)];
1229
1230        let runs = diff.runs();
1231
1232        assert_eq!(runs.len(), 1);
1233        assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1234    }
1235
1236    #[test]
1237    fn runs_handles_multiple_rows() {
1238        let old = Buffer::new(10, 10);
1239        let mut new = Buffer::new(10, 10);
1240
1241        // Changes on multiple rows
1242        new.set_raw(0, 0, Cell::from_char('A'));
1243        new.set_raw(1, 0, Cell::from_char('B'));
1244        new.set_raw(5, 2, Cell::from_char('C'));
1245        new.set_raw(0, 5, Cell::from_char('D'));
1246
1247        let diff = BufferDiff::compute(&old, &new);
1248        let runs = diff.runs();
1249
1250        assert_eq!(runs.len(), 3);
1251
1252        // Row 0: (0-1)
1253        assert_eq!(runs[0].y, 0);
1254        assert_eq!(runs[0].x0, 0);
1255        assert_eq!(runs[0].x1, 1);
1256
1257        // Row 2: (5)
1258        assert_eq!(runs[1].y, 2);
1259        assert_eq!(runs[1].x0, 5);
1260        assert_eq!(runs[1].x1, 5);
1261
1262        // Row 5: (0)
1263        assert_eq!(runs[2].y, 5);
1264        assert_eq!(runs[2].x0, 0);
1265        assert_eq!(runs[2].x1, 0);
1266    }
1267
1268    #[test]
1269    fn empty_runs_from_empty_diff() {
1270        let diff = BufferDiff::new();
1271        let runs = diff.runs();
1272        assert!(runs.is_empty());
1273    }
1274
1275    #[test]
1276    fn change_run_len() {
1277        let run = ChangeRun::new(0, 5, 10);
1278        assert_eq!(run.len(), 6);
1279
1280        let single = ChangeRun::new(0, 5, 5);
1281        assert_eq!(single.len(), 1);
1282    }
1283
1284    #[test]
1285    fn color_changes_detected() {
1286        let old = Buffer::new(10, 10);
1287        let mut new = Buffer::new(10, 10);
1288
1289        // Same empty content but different color
1290        new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1291
1292        let diff = BufferDiff::compute(&old, &new);
1293        assert_eq!(diff.len(), 1);
1294    }
1295
1296    #[test]
1297    fn diff_iter() {
1298        let old = Buffer::new(5, 5);
1299        let mut new = Buffer::new(5, 5);
1300        new.set_raw(1, 1, Cell::from_char('X'));
1301        new.set_raw(2, 2, Cell::from_char('Y'));
1302
1303        let diff = BufferDiff::compute(&old, &new);
1304        let positions: Vec<_> = diff.iter().collect();
1305
1306        assert_eq!(positions.len(), 2);
1307        assert!(positions.contains(&(1, 1)));
1308        assert!(positions.contains(&(2, 2)));
1309    }
1310
1311    #[test]
1312    fn diff_clear() {
1313        let old = Buffer::new(5, 5);
1314        let mut new = Buffer::new(5, 5);
1315        new.set_raw(1, 1, Cell::from_char('X'));
1316
1317        let mut diff = BufferDiff::compute(&old, &new);
1318        assert_eq!(diff.len(), 1);
1319
1320        diff.clear();
1321        assert!(diff.is_empty());
1322    }
1323
1324    #[test]
1325    fn with_capacity() {
1326        let diff = BufferDiff::with_capacity(100);
1327        assert!(diff.is_empty());
1328    }
1329
1330    #[test]
1331    fn full_buffer_change() {
1332        let old = Buffer::new(5, 5);
1333        let mut new = Buffer::new(5, 5);
1334
1335        // Change every cell
1336        for y in 0..5 {
1337            for x in 0..5 {
1338                new.set_raw(x, y, Cell::from_char('#'));
1339            }
1340        }
1341
1342        let diff = BufferDiff::compute(&old, &new);
1343        assert_eq!(diff.len(), 25);
1344
1345        // Should coalesce into 5 runs (one per row)
1346        let runs = diff.runs();
1347        assert_eq!(runs.len(), 5);
1348
1349        for (i, run) in runs.iter().enumerate() {
1350            assert_eq!(run.y, i as u16);
1351            assert_eq!(run.x0, 0);
1352            assert_eq!(run.x1, 4);
1353            assert_eq!(run.len(), 5);
1354        }
1355    }
1356
1357    #[test]
1358    fn row_major_order_preserved() {
1359        let old = Buffer::new(3, 3);
1360        let mut new = Buffer::new(3, 3);
1361
1362        // Set cells in non-row-major order
1363        new.set_raw(2, 2, Cell::from_char('C'));
1364        new.set_raw(0, 0, Cell::from_char('A'));
1365        new.set_raw(1, 1, Cell::from_char('B'));
1366
1367        let diff = BufferDiff::compute(&old, &new);
1368
1369        // Row-major scan should produce (0,0), (1,1), (2,2)
1370        let changes = diff.changes();
1371        assert_eq!(changes[0], (0, 0));
1372        assert_eq!(changes[1], (1, 1));
1373        assert_eq!(changes[2], (2, 2));
1374    }
1375
1376    #[test]
1377    fn blockwise_scan_preserves_sparse_row_changes() {
1378        let old = Buffer::new(64, 2);
1379        let mut new = old.clone();
1380
1381        new.set_raw(1, 0, Cell::from_char('A'));
1382        new.set_raw(33, 0, Cell::from_char('B'));
1383        new.set_raw(62, 1, Cell::from_char('C'));
1384
1385        let diff = BufferDiff::compute(&old, &new);
1386        assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1387    }
1388
1389    #[test]
1390    fn rows_with_no_changes_are_skipped() {
1391        let old = Buffer::new(4, 3);
1392        let mut new = old.clone();
1393
1394        new.set_raw(1, 1, Cell::from_char('X'));
1395        new.set_raw(3, 1, Cell::from_char('Y'));
1396
1397        let diff = BufferDiff::compute(&old, &new);
1398        assert_eq!(diff.len(), 2);
1399        assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1400    }
1401
1402    #[test]
1403    fn clear_retains_capacity_for_reuse() {
1404        let mut diff = BufferDiff::with_capacity(16);
1405        diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1406        let capacity = diff.changes.capacity();
1407
1408        diff.clear();
1409
1410        assert!(diff.is_empty());
1411        assert!(diff.changes.capacity() >= capacity);
1412    }
1413
1414    #[test]
1415    #[should_panic(expected = "buffer widths must match")]
1416    fn compute_panics_on_width_mismatch() {
1417        let old = Buffer::new(5, 5);
1418        let new = Buffer::new(4, 5);
1419        let _ = BufferDiff::compute(&old, &new);
1420    }
1421
1422    // =========================================================================
1423    // Block-based Row Scan Tests (bd-4kq0.1.2)
1424    // =========================================================================
1425
1426    #[test]
1427    fn block_scan_alignment_exact_block() {
1428        // Width = 4 (exactly one block, no remainder)
1429        let old = Buffer::new(4, 1);
1430        let mut new = Buffer::new(4, 1);
1431        new.set_raw(2, 0, Cell::from_char('X'));
1432
1433        let diff = BufferDiff::compute(&old, &new);
1434        assert_eq!(diff.len(), 1);
1435        assert_eq!(diff.changes(), &[(2, 0)]);
1436    }
1437
1438    #[test]
1439    fn block_scan_alignment_remainder() {
1440        // Width = 7 (one full block + 3 remainder)
1441        let old = Buffer::new(7, 1);
1442        let mut new = Buffer::new(7, 1);
1443        // Change in full block part
1444        new.set_raw(1, 0, Cell::from_char('A'));
1445        // Change in remainder part
1446        new.set_raw(5, 0, Cell::from_char('B'));
1447        new.set_raw(6, 0, Cell::from_char('C'));
1448
1449        let diff = BufferDiff::compute(&old, &new);
1450        assert_eq!(diff.len(), 3);
1451        assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1452    }
1453
1454    #[test]
1455    fn block_scan_single_cell_row() {
1456        // Width = 1 (pure remainder, no full blocks)
1457        let old = Buffer::new(1, 1);
1458        let mut new = Buffer::new(1, 1);
1459        new.set_raw(0, 0, Cell::from_char('X'));
1460
1461        let diff = BufferDiff::compute(&old, &new);
1462        assert_eq!(diff.len(), 1);
1463        assert_eq!(diff.changes(), &[(0, 0)]);
1464    }
1465
1466    #[test]
1467    fn block_scan_two_cell_row() {
1468        // Width = 2 (pure remainder)
1469        let old = Buffer::new(2, 1);
1470        let mut new = Buffer::new(2, 1);
1471        new.set_raw(0, 0, Cell::from_char('A'));
1472        new.set_raw(1, 0, Cell::from_char('B'));
1473
1474        let diff = BufferDiff::compute(&old, &new);
1475        assert_eq!(diff.len(), 2);
1476        assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
1477    }
1478
1479    #[test]
1480    fn block_scan_three_cell_row() {
1481        // Width = 3 (pure remainder)
1482        let old = Buffer::new(3, 1);
1483        let mut new = Buffer::new(3, 1);
1484        new.set_raw(2, 0, Cell::from_char('X'));
1485
1486        let diff = BufferDiff::compute(&old, &new);
1487        assert_eq!(diff.len(), 1);
1488        assert_eq!(diff.changes(), &[(2, 0)]);
1489    }
1490
1491    #[test]
1492    fn block_scan_multiple_blocks_sparse() {
1493        // Width = 80 (20 full blocks), changes scattered across blocks
1494        let old = Buffer::new(80, 1);
1495        let mut new = Buffer::new(80, 1);
1496
1497        // One change per block in every other block
1498        for block in (0..20).step_by(2) {
1499            let x = (block * 4 + 1) as u16;
1500            new.set_raw(x, 0, Cell::from_char('X'));
1501        }
1502
1503        let diff = BufferDiff::compute(&old, &new);
1504        assert_eq!(diff.len(), 10);
1505        assert_eq!(
1506            diff.changes(),
1507            &[
1508                (1, 0),
1509                (9, 0),
1510                (17, 0),
1511                (25, 0),
1512                (33, 0),
1513                (41, 0),
1514                (49, 0),
1515                (57, 0),
1516                (65, 0),
1517                (73, 0)
1518            ]
1519        );
1520    }
1521
1522    #[test]
1523    fn block_scan_full_block_unchanged_skip() {
1524        // Verify blocks with no changes are skipped efficiently
1525        let old = Buffer::new(20, 1);
1526        let mut new = Buffer::new(20, 1);
1527
1528        // Only change one cell in the last block
1529        new.set_raw(19, 0, Cell::from_char('Z'));
1530
1531        let diff = BufferDiff::compute(&old, &new);
1532        assert_eq!(diff.len(), 1);
1533        assert_eq!(diff.changes(), &[(19, 0)]);
1534    }
1535
1536    #[test]
1537    fn block_scan_wide_row_all_changed() {
1538        // All cells changed in a wide row
1539        let old = Buffer::new(120, 1);
1540        let mut new = Buffer::new(120, 1);
1541        for x in 0..120 {
1542            new.set_raw(x, 0, Cell::from_char('#'));
1543        }
1544
1545        let diff = BufferDiff::compute(&old, &new);
1546        assert_eq!(diff.len(), 120);
1547    }
1548
1549    #[test]
1550    fn perf_block_scan_vs_scalar_baseline() {
1551        // Verify that block scan works correctly on large buffers
1552        // and measure relative performance with structured diagnostics.
1553        use std::time::Instant;
1554
1555        let width = 200u16;
1556        let height = 50u16;
1557        let old = Buffer::new(width, height);
1558        let mut new = Buffer::new(width, height);
1559
1560        // ~10% cells changed
1561        for i in 0..1000 {
1562            let x = (i * 7 + 3) as u16 % width;
1563            let y = (i * 11 + 5) as u16 % height;
1564            let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
1565            new.set_raw(x, y, Cell::from_char(ch));
1566        }
1567
1568        let iterations = 1000u32;
1569        let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
1570            .ok()
1571            .and_then(|value| value.parse::<usize>().ok())
1572            .unwrap_or(50)
1573            .clamp(1, iterations as usize);
1574        let iters_per_sample = (iterations / samples as u32).max(1) as u64;
1575
1576        let mut times_us = Vec::with_capacity(samples);
1577        let mut last_checksum = 0u64;
1578
1579        for _ in 0..samples {
1580            let start = Instant::now();
1581            for _ in 0..iters_per_sample {
1582                let diff = BufferDiff::compute(&old, &new);
1583                assert!(!diff.is_empty());
1584                last_checksum = fnv1a_hash(diff.changes());
1585            }
1586            let elapsed = start.elapsed();
1587            let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
1588            times_us.push(per_iter);
1589        }
1590
1591        times_us.sort_unstable();
1592        let len = times_us.len();
1593        let p50 = times_us[len / 2];
1594        let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1595        let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1596        let mean = times_us
1597            .iter()
1598            .copied()
1599            .map(|value| value as f64)
1600            .sum::<f64>()
1601            / len as f64;
1602        let variance = times_us
1603            .iter()
1604            .map(|value| {
1605                let delta = *value as f64 - mean;
1606                delta * delta
1607            })
1608            .sum::<f64>()
1609            / len as f64;
1610
1611        // JSONL log line for perf diagnostics (captured by --nocapture/CI artifacts).
1612        eprintln!(
1613            "{{\"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}\"}}",
1614            width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
1615        );
1616
1617        #[allow(unexpected_cfgs)]
1618        let budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
1619        assert!(
1620            p95 <= budget_us,
1621            "Diff too slow: p95={p95}µs (budget {budget_us}µs) for {width}x{height}"
1622        );
1623    }
1624    // =========================================================================
1625    // Run Coalescing Invariants (bd-4kq0.1.3)
1626    // =========================================================================
1627
1628    #[test]
1629    fn unit_run_coalescing_invariants() {
1630        // Verify runs preserve order, coverage, and contiguity for a
1631        // complex multi-row change pattern.
1632        let old = Buffer::new(80, 24);
1633        let mut new = Buffer::new(80, 24);
1634
1635        // Row 0: two separate runs (0-2) and (10-12)
1636        for x in 0..=2 {
1637            new.set_raw(x, 0, Cell::from_char('A'));
1638        }
1639        for x in 10..=12 {
1640            new.set_raw(x, 0, Cell::from_char('B'));
1641        }
1642        // Row 5: single run (40-45)
1643        for x in 40..=45 {
1644            new.set_raw(x, 5, Cell::from_char('C'));
1645        }
1646        // Row 23: single cell at end
1647        new.set_raw(79, 23, Cell::from_char('Z'));
1648
1649        let diff = BufferDiff::compute(&old, &new);
1650        let runs = diff.runs();
1651
1652        // Invariant 1: runs are sorted by (y, x0)
1653        for w in runs.windows(2) {
1654            assert!(
1655                (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
1656                "runs must be sorted: {:?} should precede {:?}",
1657                w[0],
1658                w[1]
1659            );
1660        }
1661
1662        // Invariant 2: total cells in runs == diff.len()
1663        let total_cells: usize = runs.iter().map(|r| r.len() as usize).sum();
1664        assert_eq!(
1665            total_cells,
1666            diff.len(),
1667            "runs must cover all changes exactly"
1668        );
1669
1670        // Invariant 3: no two runs on the same row are adjacent (should have merged)
1671        for w in runs.windows(2) {
1672            if w[0].y == w[1].y {
1673                assert!(
1674                    w[1].x0 > w[0].x1.saturating_add(1),
1675                    "adjacent runs on same row should be merged: {:?} and {:?}",
1676                    w[0],
1677                    w[1]
1678                );
1679            }
1680        }
1681
1682        // Invariant 4: expected structure
1683        assert_eq!(runs.len(), 4);
1684        assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
1685        assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
1686        assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
1687        assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
1688    }
1689
1690    // =========================================================================
1691    // Golden Output Fixtures (bd-4kq0.1.3)
1692    // =========================================================================
1693
1694    /// FNV-1a hash for deterministic checksums (no external dependency).
1695    fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
1696        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1697        for &(x, y) in data {
1698            for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
1699                hash ^= *byte as u64;
1700                hash = hash.wrapping_mul(0x0100_0000_01b3);
1701            }
1702        }
1703        hash
1704    }
1705
1706    /// Build a canonical "dashboard-like" scene: header row, status bar,
1707    /// scattered content cells.
1708    fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
1709        let mut buf = Buffer::new(width, height);
1710        let mut rng = seed;
1711
1712        // Header row: all cells set
1713        for x in 0..width {
1714            buf.set_raw(x, 0, Cell::from_char('='));
1715        }
1716
1717        // Status bar (last row)
1718        for x in 0..width {
1719            buf.set_raw(x, height - 1, Cell::from_char('-'));
1720        }
1721
1722        // Scattered content using simple LCG
1723        let count = (width as u64 * height as u64 / 10).max(5);
1724        for _ in 0..count {
1725            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1726            let x = ((rng >> 16) as u16) % width;
1727            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1728            let y = ((rng >> 16) as u16) % height;
1729            rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1730            let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
1731            buf.set_raw(x, y, Cell::from_char(ch));
1732        }
1733
1734        buf
1735    }
1736
1737    #[test]
1738    fn golden_diff_80x24() {
1739        let old = Buffer::new(80, 24);
1740        let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
1741
1742        let diff = BufferDiff::compute(&old, &new);
1743        let checksum = fnv1a_hash(diff.changes());
1744
1745        // Verify determinism: same inputs → same output
1746        let diff2 = BufferDiff::compute(&old, &new);
1747        assert_eq!(
1748            fnv1a_hash(diff2.changes()),
1749            checksum,
1750            "diff must be deterministic"
1751        );
1752
1753        // Sanity: scene has header + status + scattered content
1754        assert!(
1755            diff.len() >= 160,
1756            "80x24 golden scene should have at least 160 changes (header+status), got {}",
1757            diff.len()
1758        );
1759
1760        let runs = diff.runs();
1761        // Header and status rows should each be one run
1762        assert_eq!(runs[0].y, 0, "first run should be header row");
1763        assert_eq!(runs[0].x0, 0);
1764        assert_eq!(runs[0].x1, 79);
1765        assert!(
1766            runs.last().unwrap().y == 23,
1767            "last row should contain status bar"
1768        );
1769    }
1770
1771    #[test]
1772    fn golden_diff_120x40() {
1773        let old = Buffer::new(120, 40);
1774        let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
1775
1776        let diff = BufferDiff::compute(&old, &new);
1777        let checksum = fnv1a_hash(diff.changes());
1778
1779        // Determinism
1780        let diff2 = BufferDiff::compute(&old, &new);
1781        assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1782
1783        // Sanity
1784        assert!(
1785            diff.len() >= 240,
1786            "120x40 golden scene should have >=240 changes, got {}",
1787            diff.len()
1788        );
1789
1790        // Dirty diff must match
1791        let dirty = BufferDiff::compute_dirty(&old, &new);
1792        assert_eq!(
1793            fnv1a_hash(dirty.changes()),
1794            checksum,
1795            "dirty diff must produce identical changes"
1796        );
1797    }
1798
1799    #[test]
1800    fn golden_sparse_update() {
1801        // Start from a populated scene, apply a small update
1802        let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
1803        let mut new = old.clone();
1804
1805        // Apply 5 deterministic changes
1806        new.set_raw(10, 5, Cell::from_char('!'));
1807        new.set_raw(11, 5, Cell::from_char('@'));
1808        new.set_raw(40, 12, Cell::from_char('#'));
1809        new.set_raw(70, 20, Cell::from_char('$'));
1810        new.set_raw(0, 23, Cell::from_char('%'));
1811
1812        let diff = BufferDiff::compute(&old, &new);
1813        let checksum = fnv1a_hash(diff.changes());
1814
1815        // Determinism
1816        let diff2 = BufferDiff::compute(&old, &new);
1817        assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1818
1819        // Exactly 5 changes (or fewer if some cells happened to already have that value)
1820        assert!(
1821            diff.len() <= 5,
1822            "sparse update should have <=5 changes, got {}",
1823            diff.len()
1824        );
1825        assert!(
1826            diff.len() >= 3,
1827            "sparse update should have >=3 changes, got {}",
1828            diff.len()
1829        );
1830    }
1831
1832    // =========================================================================
1833    // E2E Random Scene Replay (bd-4kq0.1.3)
1834    // =========================================================================
1835
1836    #[test]
1837    fn e2e_random_scene_replay() {
1838        // 10 frames of seeded scene evolution, verify checksums are
1839        // deterministic across replays and dirty/full paths agree.
1840        let width = 80u16;
1841        let height = 24u16;
1842        let base_seed: u64 = 0x5C3E_E3E1_A442u64;
1843
1844        let mut checksums = Vec::new();
1845
1846        for frame in 0..10u64 {
1847            let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1848            let old = build_golden_scene(width, height, seed);
1849            let new = build_golden_scene(width, height, seed.wrapping_add(1));
1850
1851            let diff = BufferDiff::compute(&old, &new);
1852            let dirty_diff = BufferDiff::compute_dirty(&old, &new);
1853
1854            // Full and dirty must agree
1855            assert_eq!(
1856                diff.changes(),
1857                dirty_diff.changes(),
1858                "frame {frame}: dirty diff must match full diff"
1859            );
1860
1861            checksums.push(fnv1a_hash(diff.changes()));
1862        }
1863
1864        // Replay: same seeds must produce identical checksums
1865        for frame in 0..10u64 {
1866            let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1867            let old = build_golden_scene(width, height, seed);
1868            let new = build_golden_scene(width, height, seed.wrapping_add(1));
1869
1870            let diff = BufferDiff::compute(&old, &new);
1871            assert_eq!(
1872                fnv1a_hash(diff.changes()),
1873                checksums[frame as usize],
1874                "frame {frame}: checksum mismatch on replay"
1875            );
1876        }
1877    }
1878
1879    // =========================================================================
1880    // Perf Microbench with JSONL (bd-4kq0.1.3)
1881    // =========================================================================
1882
1883    #[test]
1884    fn perf_diff_microbench() {
1885        use std::time::Instant;
1886
1887        let scenarios: &[(u16, u16, &str, u64)] = &[
1888            (80, 24, "full_frame", 0xBE4C_0001u64),
1889            (80, 24, "sparse_update", 0xBE4C_0002u64),
1890            (120, 40, "full_frame", 0xBE4C_0003u64),
1891            (120, 40, "sparse_update", 0xBE4C_0004u64),
1892        ];
1893
1894        let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1895            .ok()
1896            .and_then(|value| value.parse::<u32>().ok())
1897            .unwrap_or(50u32);
1898
1899        for &(width, height, scene_type, seed) in scenarios {
1900            let old = Buffer::new(width, height);
1901            let new = match scene_type {
1902                "full_frame" => build_golden_scene(width, height, seed),
1903                "sparse_update" => {
1904                    let mut buf = old.clone();
1905                    buf.set_raw(10, 5, Cell::from_char('!'));
1906                    buf.set_raw(40, 12, Cell::from_char('#'));
1907                    buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
1908                    buf
1909                }
1910                _ => unreachable!(),
1911            };
1912
1913            let mut times_us = Vec::with_capacity(iterations as usize);
1914            let mut last_changes = 0usize;
1915            let mut last_runs = 0usize;
1916            let mut last_checksum = 0u64;
1917
1918            for _ in 0..iterations {
1919                let start = Instant::now();
1920                let diff = BufferDiff::compute(&old, &new);
1921                let runs = diff.runs();
1922                let elapsed = start.elapsed();
1923
1924                last_changes = diff.len();
1925                last_runs = runs.len();
1926                last_checksum = fnv1a_hash(diff.changes());
1927                times_us.push(elapsed.as_micros() as u64);
1928            }
1929
1930            times_us.sort();
1931            let len = times_us.len();
1932            let p50 = times_us[len / 2];
1933            let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1934            let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1935
1936            // JSONL log line (captured by --nocapture or CI artifact)
1937            eprintln!(
1938                "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
1939                seed,
1940                width,
1941                height,
1942                scene_type,
1943                last_changes,
1944                last_runs,
1945                p50,
1946                p95,
1947                p99,
1948                last_checksum
1949            );
1950
1951            // Budget: full 80x24 diff should be < 100µs at p95
1952            // Budget: full 120x40 diff should be < 200µs at p95
1953            let budget_us = match (width, height) {
1954                (80, 24) => 500,   // generous for CI variance
1955                (120, 40) => 1000, // generous for CI variance
1956                _ => 2000,
1957            };
1958
1959            // Checksum must be identical across all iterations (determinism)
1960            for _ in 0..3 {
1961                let diff = BufferDiff::compute(&old, &new);
1962                assert_eq!(
1963                    fnv1a_hash(diff.changes()),
1964                    last_checksum,
1965                    "diff must be deterministic"
1966                );
1967            }
1968
1969            // Soft budget assertion (warn but don't fail on slow CI)
1970            if p95 > budget_us {
1971                eprintln!(
1972                    "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
1973                );
1974            }
1975        }
1976    }
1977
1978    // =========================================================================
1979    // Dirty vs Full Diff Regression Gate (bd-3e1t.1.6)
1980    // =========================================================================
1981
1982    #[test]
1983    fn perf_dirty_diff_large_screen_regression() {
1984        use std::time::Instant;
1985
1986        let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1987            .ok()
1988            .and_then(|value| value.parse::<u32>().ok())
1989            .unwrap_or(50u32);
1990
1991        let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
1992            .ok()
1993            .and_then(|value| value.parse::<f64>().ok())
1994            .unwrap_or(2.0);
1995
1996        let cases: &[(u16, u16, &str, f64)] = &[
1997            (200, 60, "sparse_5pct", 5.0),
1998            (240, 80, "sparse_5pct", 5.0),
1999            (200, 60, "single_row", 0.0),
2000            (240, 80, "single_row", 0.0),
2001        ];
2002
2003        for &(width, height, pattern, pct) in cases {
2004            let old = Buffer::new(width, height);
2005            let mut new = old.clone();
2006
2007            if pattern == "single_row" {
2008                for x in 0..width {
2009                    new.set_raw(x, 0, Cell::from_char('X'));
2010                }
2011            } else {
2012                let total = width as usize * height as usize;
2013                let to_change = ((total as f64) * pct / 100.0) as usize;
2014                for i in 0..to_change {
2015                    let x = (i * 7 + 3) as u16 % width;
2016                    let y = (i * 11 + 5) as u16 % height;
2017                    let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2018                    new.set_raw(x, y, Cell::from_char(ch));
2019                }
2020            }
2021
2022            // Sanity: dirty and full diffs must agree.
2023            let full = BufferDiff::compute(&old, &new);
2024            let dirty = BufferDiff::compute_dirty(&old, &new);
2025            let change_count = full.len();
2026            let dirty_rows = new.dirty_row_count();
2027            assert_eq!(
2028                full.changes(),
2029                dirty.changes(),
2030                "dirty diff must match full diff for {width}x{height} {pattern}"
2031            );
2032
2033            let mut full_times = Vec::with_capacity(iterations as usize);
2034            let mut dirty_times = Vec::with_capacity(iterations as usize);
2035
2036            for _ in 0..iterations {
2037                let start = Instant::now();
2038                let diff = BufferDiff::compute(&old, &new);
2039                std::hint::black_box(diff.len());
2040                full_times.push(start.elapsed().as_micros() as u64);
2041
2042                let start = Instant::now();
2043                let diff = BufferDiff::compute_dirty(&old, &new);
2044                std::hint::black_box(diff.len());
2045                dirty_times.push(start.elapsed().as_micros() as u64);
2046            }
2047
2048            full_times.sort();
2049            dirty_times.sort();
2050
2051            let len = full_times.len();
2052            let p50_idx = len / 2;
2053            let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2054
2055            let full_p50 = full_times[p50_idx];
2056            let full_p95 = full_times[p95_idx];
2057            let dirty_p50 = dirty_times[p50_idx];
2058            let dirty_p95 = dirty_times[p95_idx];
2059
2060            let denom = full_p50.max(1) as f64;
2061            let ratio = dirty_p50 as f64 / denom;
2062
2063            eprintln!(
2064                "{{\"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\":{}}}",
2065                width,
2066                height,
2067                pattern,
2068                iterations,
2069                change_count,
2070                dirty_rows,
2071                full_p50,
2072                full_p95,
2073                dirty_p50,
2074                dirty_p95,
2075                ratio,
2076                max_slowdown
2077            );
2078
2079            assert!(
2080                ratio <= max_slowdown,
2081                "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2082            );
2083        }
2084    }
2085
2086    #[test]
2087    fn tile_diff_matches_compute_for_sparse_tiles() {
2088        let width = 200;
2089        let height = 60;
2090        let old = Buffer::new(width, height);
2091        let mut new = old.clone();
2092
2093        new.clear_dirty();
2094        for x in 0..10u16 {
2095            new.set_raw(x, 0, Cell::from_char('X'));
2096        }
2097
2098        let full = BufferDiff::compute(&old, &new);
2099        let dirty = BufferDiff::compute_dirty(&old, &new);
2100
2101        assert_eq!(full.changes(), dirty.changes());
2102        let stats = dirty
2103            .last_tile_stats()
2104            .expect("tile stats should be recorded");
2105        assert!(
2106            stats.fallback.is_none(),
2107            "tile path should be used for sparse tiles"
2108        );
2109    }
2110
2111    fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2112        let mut buffer = Buffer::new(width, height);
2113        buffer.clear_dirty();
2114        for &(x, y, ch) in changes {
2115            buffer.set_raw(x, y, Cell::from_char(ch));
2116        }
2117        buffer
2118    }
2119
2120    fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2121        let mut diff = BufferDiff::new();
2122        *diff.tile_config_mut() = config;
2123        diff.compute_dirty_into(old, new);
2124        diff.last_tile_stats()
2125            .expect("tile stats should be recorded")
2126    }
2127
2128    #[test]
2129    fn tile_fallback_disabled_when_config_off() {
2130        let width = 64;
2131        let height = 32;
2132        let old = Buffer::new(width, height);
2133        let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2134
2135        let config = TileDiffConfig {
2136            enabled: false,
2137            min_cells_for_tiles: 0,
2138            dense_cell_ratio: 1.1,
2139            dense_tile_ratio: 1.1,
2140            max_tiles: usize::MAX,
2141            ..Default::default()
2142        };
2143
2144        let stats = tile_stats_for_config(&old, &new, config);
2145        assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2146    }
2147
2148    #[test]
2149    fn tile_fallback_small_screen_when_below_threshold() {
2150        let width = 64;
2151        let height = 32;
2152        let old = Buffer::new(width, height);
2153        let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2154
2155        let config = TileDiffConfig {
2156            enabled: true,
2157            min_cells_for_tiles: width as usize * height as usize + 1,
2158            dense_cell_ratio: 1.1,
2159            dense_tile_ratio: 1.1,
2160            max_tiles: usize::MAX,
2161            ..Default::default()
2162        };
2163
2164        let stats = tile_stats_for_config(&old, &new, config);
2165        assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2166    }
2167
2168    #[test]
2169    fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2170        let width = 64;
2171        let height = 64;
2172        let old = Buffer::new(width, height);
2173        let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2174
2175        let config = TileDiffConfig {
2176            enabled: true,
2177            tile_w: 8,
2178            tile_h: 8,
2179            min_cells_for_tiles: 0,
2180            dense_cell_ratio: 1.1,
2181            dense_tile_ratio: 1.1,
2182            max_tiles: 4,
2183        };
2184
2185        let stats = tile_stats_for_config(&old, &new, config);
2186        assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2187    }
2188
2189    #[test]
2190    fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2191        let width = 64;
2192        let height = 32;
2193        let old = Buffer::new(width, height);
2194        let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2195
2196        let config = TileDiffConfig {
2197            enabled: true,
2198            tile_w: 8,
2199            tile_h: 8,
2200            min_cells_for_tiles: 0,
2201            dense_cell_ratio: 1.1,
2202            dense_tile_ratio: 0.05,
2203            max_tiles: usize::MAX / 4,
2204        };
2205
2206        let stats = tile_stats_for_config(&old, &new, config);
2207        assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2208    }
2209
2210    fn lcg_next(state: &mut u64) -> u64 {
2211        *state = state
2212            .wrapping_mul(6364136223846793005)
2213            .wrapping_add(1442695040888963407);
2214        *state
2215    }
2216
2217    fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2218        let width = buf.width().max(1) as u64;
2219        let height = buf.height().max(1) as u64;
2220        let mut state = seed;
2221        for i in 0..count {
2222            let v = lcg_next(&mut state);
2223            let x = (v % width) as u16;
2224            let y = ((v >> 32) % height) as u16;
2225            let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2226            buf.set_raw(x, y, Cell::from_char(ch));
2227        }
2228    }
2229
2230    fn tile_diag(stats: &TileDiffStats) -> String {
2231        let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2232        format!(
2233            "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2234            stats.dirty_tiles,
2235            stats.skipped_tiles,
2236            stats.dirty_cells,
2237            stats.dirty_tile_ratio,
2238            stats.dirty_cell_ratio,
2239            stats.scanned_tiles,
2240            stats.fallback
2241        )
2242    }
2243
2244    fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2245        let mut diff = BufferDiff::new();
2246        {
2247            let config = diff.tile_config_mut();
2248            config.enabled = true;
2249            config.tile_w = 8;
2250            config.tile_h = 8;
2251            config.min_cells_for_tiles = 0;
2252            config.dense_cell_ratio = 1.1;
2253            config.dense_tile_ratio = 1.1;
2254            config.max_tiles = usize::MAX / 4;
2255        }
2256        diff.compute_dirty_into(old, new);
2257        let stats = diff
2258            .last_tile_stats()
2259            .expect("tile stats should be recorded");
2260        (diff, stats)
2261    }
2262
2263    fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2264        let full = BufferDiff::compute(old, new);
2265        let (dirty, stats) = diff_with_forced_tiles(old, new);
2266        let diag = tile_diag(&stats);
2267        assert!(
2268            stats.fallback.is_none(),
2269            "tile diff fallback ({label}) {w}x{h}: {diag}",
2270            w = old.width(),
2271            h = old.height()
2272        );
2273        assert!(
2274            full.changes() == dirty.changes(),
2275            "tile diff mismatch ({label}) {w}x{h}: {diag}",
2276            w = old.width(),
2277            h = old.height()
2278        );
2279    }
2280
2281    #[test]
2282    fn tile_diff_equivalence_small_and_odd_sizes() {
2283        let cases: &[(u16, u16, usize)] = &[
2284            (1, 1, 1),
2285            (2, 1, 1),
2286            (1, 2, 1),
2287            (5, 3, 4),
2288            (7, 13, 12),
2289            (15, 9, 20),
2290            (31, 5, 12),
2291        ];
2292
2293        for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2294            let old = Buffer::new(width, height);
2295            let mut new = old.clone();
2296            new.clear_dirty();
2297            apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2298            assert_tile_diff_equivalence(&old, &new, "small_odd");
2299        }
2300    }
2301
2302    #[test]
2303    fn tile_diff_equivalence_large_sparse_random() {
2304        let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2305        for (idx, &(width, height)) in cases.iter().enumerate() {
2306            let old = Buffer::new(width, height);
2307            let mut new = old.clone();
2308            new.clear_dirty();
2309            let total = width as usize * height as usize;
2310            let changes = (total / 100).max(1);
2311            apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2312            assert_tile_diff_equivalence(&old, &new, "large_sparse");
2313        }
2314    }
2315
2316    #[test]
2317    fn tile_diff_equivalence_row_and_full_buffer() {
2318        let width = 200u16;
2319        let height = 60u16;
2320        let old = Buffer::new(width, height);
2321
2322        let mut row = old.clone();
2323        row.clear_dirty();
2324        for x in 0..width {
2325            row.set_raw(x, 0, Cell::from_char('R'));
2326        }
2327        assert_tile_diff_equivalence(&old, &row, "single_row");
2328
2329        let mut full = old.clone();
2330        full.clear_dirty();
2331        for y in 0..height {
2332            for x in 0..width {
2333                full.set_raw(x, y, Cell::from_char('F'));
2334            }
2335        }
2336        assert_tile_diff_equivalence(&old, &full, "full_buffer");
2337    }
2338}
2339
2340#[cfg(test)]
2341mod proptests {
2342    use super::*;
2343    use crate::cell::Cell;
2344    use ftui_core::geometry::Rect;
2345    use proptest::prelude::*;
2346
2347    // Property: Applying diff changes to old buffer produces new buffer.
2348    #[test]
2349    fn diff_apply_produces_target() {
2350        proptest::proptest!(|(
2351            width in 5u16..50,
2352            height in 5u16..30,
2353            num_changes in 0usize..200,
2354        )| {
2355            // Create old buffer (all spaces)
2356            let old = Buffer::new(width, height);
2357
2358            // Create new buffer by making random changes
2359            let mut new = old.clone();
2360            for i in 0..num_changes {
2361                let x = (i * 7 + 3) as u16 % width;
2362                let y = (i * 11 + 5) as u16 % height;
2363                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2364                new.set_raw(x, y, Cell::from_char(ch));
2365            }
2366
2367            // Compute diff
2368            let diff = BufferDiff::compute(&old, &new);
2369
2370            // Apply diff to old should produce new
2371            let mut result = old.clone();
2372            for (x, y) in diff.iter() {
2373                let cell = *new.get_unchecked(x, y);
2374                result.set_raw(x, y, cell);
2375            }
2376
2377            // Verify buffers match
2378            for y in 0..height {
2379                for x in 0..width {
2380                    let result_cell = result.get_unchecked(x, y);
2381                    let new_cell = new.get_unchecked(x, y);
2382                    prop_assert!(
2383                        result_cell.bits_eq(new_cell),
2384                        "Mismatch at ({}, {})",
2385                        x,
2386                        y
2387                    );
2388                }
2389            }
2390        });
2391    }
2392
2393    // Property: Diff is empty when buffers are identical.
2394    #[test]
2395    fn identical_buffers_empty_diff() {
2396        proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
2397            let buf = Buffer::new(width, height);
2398            let diff = BufferDiff::compute(&buf, &buf);
2399            prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
2400        });
2401    }
2402
2403    // Property: Every change in diff corresponds to an actual difference.
2404    #[test]
2405    fn diff_contains_only_real_changes() {
2406        proptest::proptest!(|(
2407            width in 5u16..50,
2408            height in 5u16..30,
2409            num_changes in 0usize..100,
2410        )| {
2411            let old = Buffer::new(width, height);
2412            let mut new = old.clone();
2413
2414            for i in 0..num_changes {
2415                let x = (i * 7 + 3) as u16 % width;
2416                let y = (i * 11 + 5) as u16 % height;
2417                new.set_raw(x, y, Cell::from_char('X'));
2418            }
2419
2420            let diff = BufferDiff::compute(&old, &new);
2421
2422            // Every change position should actually differ
2423            for (x, y) in diff.iter() {
2424                let old_cell = old.get_unchecked(x, y);
2425                let new_cell = new.get_unchecked(x, y);
2426                prop_assert!(
2427                    !old_cell.bits_eq(new_cell),
2428                    "Diff includes unchanged cell at ({}, {})",
2429                    x,
2430                    y
2431                );
2432            }
2433        });
2434    }
2435
2436    // Property: Runs correctly coalesce adjacent changes.
2437    #[test]
2438    fn runs_are_contiguous() {
2439        proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
2440            let old = Buffer::new(width, height);
2441            let mut new = old.clone();
2442
2443            // Create some horizontal runs
2444            for y in 0..height.min(5) {
2445                for x in 0..width.min(10) {
2446                    new.set_raw(x, y, Cell::from_char('#'));
2447                }
2448            }
2449
2450            let diff = BufferDiff::compute(&old, &new);
2451            let runs = diff.runs();
2452
2453            // Verify each run is contiguous
2454            for run in runs {
2455                prop_assert!(run.x1 >= run.x0, "Run has invalid range");
2456                prop_assert!(!run.is_empty(), "Run should not be empty");
2457
2458                // Verify all cells in run are actually changed
2459                for x in run.x0..=run.x1 {
2460                    let old_cell = old.get_unchecked(x, run.y);
2461                    let new_cell = new.get_unchecked(x, run.y);
2462                    prop_assert!(
2463                        !old_cell.bits_eq(new_cell),
2464                        "Run includes unchanged cell at ({}, {})",
2465                        x,
2466                        run.y
2467                    );
2468                }
2469            }
2470        });
2471    }
2472
2473    // Property: Runs cover all changes exactly once.
2474    #[test]
2475    fn runs_cover_all_changes() {
2476        proptest::proptest!(|(
2477            width in 10u16..60,
2478            height in 5u16..30,
2479            num_changes in 1usize..100,
2480        )| {
2481            let old = Buffer::new(width, height);
2482            let mut new = old.clone();
2483
2484            for i in 0..num_changes {
2485                let x = (i * 13 + 7) as u16 % width;
2486                let y = (i * 17 + 3) as u16 % height;
2487                new.set_raw(x, y, Cell::from_char('X'));
2488            }
2489
2490            let diff = BufferDiff::compute(&old, &new);
2491            let runs = diff.runs();
2492
2493            // Count cells covered by runs
2494            let mut run_cells: std::collections::HashSet<(u16, u16)> =
2495                std::collections::HashSet::new();
2496            for run in &runs {
2497                for x in run.x0..=run.x1 {
2498                    let was_new = run_cells.insert((x, run.y));
2499                    prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
2500                }
2501            }
2502
2503            // Verify runs cover exactly the changes
2504            for (x, y) in diff.iter() {
2505                prop_assert!(
2506                    run_cells.contains(&(x, y)),
2507                    "Change at ({}, {}) not covered by runs",
2508                    x,
2509                    y
2510                );
2511            }
2512
2513            prop_assert_eq!(
2514                run_cells.len(),
2515                diff.len(),
2516                "Run cell count should match diff change count"
2517            );
2518        });
2519    }
2520
2521    // Property (bd-4kq0.1.2): Block-based scan matches scalar scan
2522    // for random row widths and change patterns. This verifies the
2523    // block/remainder handling is correct for all alignment cases.
2524    #[test]
2525    fn block_scan_matches_scalar() {
2526        proptest::proptest!(|(
2527            width in 1u16..200,
2528            height in 1u16..20,
2529            num_changes in 0usize..200,
2530        )| {
2531            use crate::cell::PackedRgba;
2532
2533            let old = Buffer::new(width, height);
2534            let mut new = Buffer::new(width, height);
2535
2536            for i in 0..num_changes {
2537                let x = (i * 13 + 7) as u16 % width;
2538                let y = (i * 17 + 3) as u16 % height;
2539                let fg = PackedRgba::rgb(
2540                    ((i * 31) % 256) as u8,
2541                    ((i * 47) % 256) as u8,
2542                    ((i * 71) % 256) as u8,
2543                );
2544                new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
2545            }
2546
2547            let diff = BufferDiff::compute(&old, &new);
2548
2549            // Verify against manual scalar scan
2550            let mut scalar_changes = Vec::new();
2551            for y in 0..height {
2552                for x in 0..width {
2553                    let old_cell = old.get_unchecked(x, y);
2554                    let new_cell = new.get_unchecked(x, y);
2555                    if !old_cell.bits_eq(new_cell) {
2556                        scalar_changes.push((x, y));
2557                    }
2558                }
2559            }
2560
2561            prop_assert_eq!(
2562                diff.changes(),
2563                &scalar_changes[..],
2564                "Block scan should match scalar scan"
2565            );
2566        });
2567    }
2568
2569    // ========== Diff Equivalence: dirty+block vs full scan (bd-4kq0.1.3) ==========
2570
2571    // Property: compute_dirty with all rows dirty matches compute exactly.
2572    // This verifies the block-scan + dirty-row path is semantically
2573    // equivalent to the full scan for random buffers.
2574    #[test]
2575    fn property_diff_equivalence() {
2576        proptest::proptest!(|(
2577            width in 1u16..120,
2578            height in 1u16..40,
2579            num_changes in 0usize..300,
2580        )| {
2581            let old = Buffer::new(width, height);
2582            let mut new = Buffer::new(width, height);
2583
2584            // Apply deterministic pseudo-random changes
2585            for i in 0..num_changes {
2586                let x = (i * 13 + 7) as u16 % width;
2587                let y = (i * 17 + 3) as u16 % height;
2588                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2589                let fg = crate::cell::PackedRgba::rgb(
2590                    ((i * 31) % 256) as u8,
2591                    ((i * 47) % 256) as u8,
2592                    ((i * 71) % 256) as u8,
2593                );
2594                new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
2595            }
2596
2597            let full = BufferDiff::compute(&old, &new);
2598            let dirty = BufferDiff::compute_dirty(&old, &new);
2599
2600            prop_assert_eq!(
2601                full.changes(),
2602                dirty.changes(),
2603                "dirty diff must match full diff (width={}, height={}, changes={})",
2604                width,
2605                height,
2606                num_changes
2607            );
2608
2609            // Also verify run coalescing is identical
2610            let full_runs = full.runs();
2611            let dirty_runs = dirty.runs();
2612            prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
2613            for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
2614                prop_assert_eq!(fr, dr, "run mismatch");
2615            }
2616        });
2617    }
2618
2619    // Property: compute_dirty matches compute for random fill/set operations.
2620    // This exercises span merging and complex dirty patterns (bd-3e1t.6.4).
2621    #[test]
2622    fn property_diff_equivalence_complex_spans() {
2623        proptest::proptest!(|(
2624            width in 10u16..100,
2625            height in 10u16..50,
2626            ops in proptest::collection::vec(
2627                prop_oneof![
2628                    // Single cell set
2629                    (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
2630                    // Region fill (small rects)
2631                    (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
2632                ],
2633                1..50
2634            )
2635        )| {
2636            let old = Buffer::new(width, height);
2637            let mut new = Buffer::new(width, height);
2638
2639            // Clear dirty state on new so we start fresh tracking
2640            new.clear_dirty();
2641
2642            for (op_type, x, y, ch) in ops {
2643                let x = x % width;
2644                let y = y % height;
2645                let cell = Cell::from_char(ch);
2646
2647                match op_type {
2648                    0 => new.set(x, y, cell),
2649                    1 => {
2650                        // Random small rect
2651                        let w = ((x + 10).min(width) - x).max(1);
2652                        let h = ((y + 5).min(height) - y).max(1);
2653                        new.fill(Rect::new(x, y, w, h), cell);
2654                    }
2655                    _ => unreachable!(),
2656                }
2657            }
2658
2659            let full = BufferDiff::compute(&old, &new);
2660            let dirty = BufferDiff::compute_dirty(&old, &new);
2661
2662            prop_assert_eq!(
2663                full.changes(),
2664                dirty.changes(),
2665                "dirty diff (spans) must match full diff; {}",
2666                super::span_diagnostics(&new)
2667            );
2668        });
2669    }
2670
2671    // ========== Idempotence Property (bd-1rz0.6) ==========
2672
2673    // Property: Diff is idempotent - computing diff between identical buffers
2674    // produces empty diff, and applying diff twice has no additional effect.
2675    //
2676    // Invariant: For any buffers A and B:
2677    //   apply(apply(A, diff(A,B)), diff(A,B)) == apply(A, diff(A,B))
2678    #[test]
2679    fn diff_is_idempotent() {
2680        proptest::proptest!(|(
2681            width in 5u16..60,
2682            height in 5u16..30,
2683            num_changes in 0usize..100,
2684        )| {
2685            let mut buf_a = Buffer::new(width, height);
2686            let mut buf_b = Buffer::new(width, height);
2687
2688            // Make buf_b different from buf_a
2689            for i in 0..num_changes {
2690                let x = (i * 13 + 7) as u16 % width;
2691                let y = (i * 17 + 3) as u16 % height;
2692                buf_b.set_raw(x, y, Cell::from_char('X'));
2693            }
2694
2695            // Compute diff from A to B
2696            let diff = BufferDiff::compute(&buf_a, &buf_b);
2697
2698            // Apply diff to A once
2699            for (x, y) in diff.iter() {
2700                let cell = *buf_b.get_unchecked(x, y);
2701                buf_a.set_raw(x, y, cell);
2702            }
2703
2704            // Now buf_a should equal buf_b
2705            let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
2706            prop_assert!(
2707                diff_after_first.is_empty(),
2708                "After applying diff once, buffers should be identical (diff was {} changes)",
2709                diff_after_first.len()
2710            );
2711
2712            // Apply diff again (should be no-op since buffers are now equal)
2713            let before_second = buf_a.clone();
2714            for (x, y) in diff.iter() {
2715                let cell = *buf_b.get_unchecked(x, y);
2716                buf_a.set_raw(x, y, cell);
2717            }
2718
2719            // Verify no change from second application
2720            let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
2721            prop_assert!(
2722                diff_after_second.is_empty(),
2723                "Second diff application should be a no-op"
2724            );
2725        });
2726    }
2727
2728    // ========== No-Ghosting After Clear Property (bd-1rz0.6) ==========
2729
2730    // Property: After a full buffer clear (simulating resize), diffing
2731    // against a blank old buffer captures all content cells.
2732    //
2733    // This simulates the no-ghosting invariant: when terminal shrinks,
2734    // we present against a fresh blank buffer, ensuring no old content
2735    // persists. The key is that all non-blank cells in the new buffer
2736    // appear in the diff.
2737    //
2738    // Failure mode: If we diff against stale buffer state after resize,
2739    // some cells might be incorrectly marked as unchanged.
2740    #[test]
2741    fn no_ghosting_after_clear() {
2742        proptest::proptest!(|(
2743            width in 10u16..80,
2744            height in 5u16..30,
2745            num_content_cells in 1usize..200,
2746        )| {
2747            // Old buffer is blank (simulating post-resize cleared state)
2748            let old = Buffer::new(width, height);
2749
2750            // New buffer has content (the UI to render)
2751            let mut new = Buffer::new(width, height);
2752            let mut expected_changes = std::collections::HashSet::new();
2753
2754            for i in 0..num_content_cells {
2755                let x = (i * 13 + 7) as u16 % width;
2756                let y = (i * 17 + 3) as u16 % height;
2757                new.set_raw(x, y, Cell::from_char('#'));
2758                expected_changes.insert((x, y));
2759            }
2760
2761            let diff = BufferDiff::compute(&old, &new);
2762
2763            // Every non-blank cell should be in the diff
2764            // This ensures no "ghosting" - all visible content is explicitly rendered
2765            for (x, y) in expected_changes {
2766                let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
2767                prop_assert!(
2768                    in_diff,
2769                    "Content cell at ({}, {}) missing from diff - would ghost",
2770                    x,
2771                    y
2772                );
2773            }
2774
2775            // Also verify the diff doesn't include any extra cells
2776            for (x, y) in diff.iter() {
2777                let old_cell = old.get_unchecked(x, y);
2778                let new_cell = new.get_unchecked(x, y);
2779                prop_assert!(
2780                    !old_cell.bits_eq(new_cell),
2781                    "Diff includes unchanged cell at ({}, {})",
2782                    x,
2783                    y
2784                );
2785            }
2786        });
2787    }
2788
2789    // ========== Monotonicity Property (bd-1rz0.6) ==========
2790
2791    // Property: Diff changes are monotonically ordered (row-major).
2792    // This ensures deterministic iteration order for presentation.
2793    //
2794    // Invariant: For consecutive changes (x1,y1) and (x2,y2):
2795    //   y1 < y2 OR (y1 == y2 AND x1 < x2)
2796    #[test]
2797    fn diff_changes_are_monotonic() {
2798        proptest::proptest!(|(
2799            width in 10u16..80,
2800            height in 5u16..30,
2801            num_changes in 1usize..200,
2802        )| {
2803            let old = Buffer::new(width, height);
2804            let mut new = old.clone();
2805
2806            // Apply changes in random positions
2807            for i in 0..num_changes {
2808                let x = (i * 37 + 11) as u16 % width;
2809                let y = (i * 53 + 7) as u16 % height;
2810                new.set_raw(x, y, Cell::from_char('M'));
2811            }
2812
2813            let diff = BufferDiff::compute(&old, &new);
2814            let changes: Vec<_> = diff.iter().collect();
2815
2816            // Verify monotonic ordering
2817            for window in changes.windows(2) {
2818                let (x1, y1) = window[0];
2819                let (x2, y2) = window[1];
2820
2821                let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
2822                prop_assert!(
2823                    is_monotonic,
2824                    "Changes not monotonic: ({}, {}) should come before ({}, {})",
2825                    x1,
2826                    y1,
2827                    x2,
2828                    y2
2829                );
2830            }
2831        });
2832    }
2833}
2834
2835#[cfg(test)]
2836mod span_edge_cases {
2837    use super::*;
2838    use crate::cell::Cell;
2839    use proptest::prelude::*;
2840
2841    #[test]
2842    fn test_span_diff_u16_max_width() {
2843        // Test near u16::MAX limit (65535)
2844        let width = 65000;
2845        let height = 1;
2846        let old = Buffer::new(width, height);
2847        let mut new = Buffer::new(width, height);
2848
2849        // We must clear dirty because Buffer::new starts with all rows dirty
2850        new.clear_dirty();
2851
2852        // Set changes at start, middle, end
2853        new.set_raw(0, 0, Cell::from_char('A'));
2854        new.set_raw(32500, 0, Cell::from_char('B'));
2855        new.set_raw(64999, 0, Cell::from_char('C'));
2856
2857        let full = BufferDiff::compute(&old, &new);
2858        let dirty = BufferDiff::compute_dirty(&old, &new);
2859
2860        assert_eq!(full.changes(), dirty.changes());
2861        assert_eq!(full.len(), 3);
2862
2863        // Verify changes are what we expect
2864        let changes = full.changes();
2865        assert!(changes.contains(&(0, 0)));
2866        assert!(changes.contains(&(32500, 0)));
2867        assert!(changes.contains(&(64999, 0)));
2868    }
2869
2870    #[test]
2871    fn test_span_full_row_dirty_overflow() {
2872        let width = 1000;
2873        let height = 1;
2874        let old = Buffer::new(width, height);
2875        let mut new = Buffer::new(width, height);
2876        new.clear_dirty(); // All clean
2877
2878        // Create > 64 spans to force overflow
2879        // DIRTY_SPAN_MAX_SPANS_PER_ROW is 64
2880        for i in 0..70 {
2881            let x = (i * 10) as u16;
2882            new.set_raw(x, 0, Cell::from_char('X'));
2883        }
2884
2885        // Verify it overflowed
2886        let stats = new.dirty_span_stats();
2887        assert!(
2888            stats.rows_full_dirty > 0,
2889            "Should have overflowed to full row"
2890        );
2891        assert_eq!(
2892            stats.rows_with_spans, 0,
2893            "Should have cleared spans on overflow"
2894        );
2895
2896        let full = BufferDiff::compute(&old, &new);
2897        let dirty = BufferDiff::compute_dirty(&old, &new);
2898
2899        assert_eq!(full.changes(), dirty.changes());
2900        assert_eq!(full.len(), 70);
2901    }
2902
2903    #[test]
2904    fn test_span_diff_empty_rows() {
2905        let width = 100;
2906        let height = 10;
2907        let old = Buffer::new(width, height);
2908        let mut new = Buffer::new(width, height);
2909        new.clear_dirty(); // All clean
2910
2911        // No changes
2912        let dirty = BufferDiff::compute_dirty(&old, &new);
2913        assert!(dirty.is_empty());
2914    }
2915
2916    proptest! {
2917        #[test]
2918        fn property_span_diff_equivalence_large(
2919            width in 1000u16..5000,
2920            height in 10u16..50,
2921            changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
2922        ) {
2923            // Cap width/height to avoid OOM in test runner
2924            let w = width.min(2000);
2925            let h = height.min(50);
2926
2927            let old = Buffer::new(w, h);
2928            let mut new = Buffer::new(w, h);
2929            new.clear_dirty();
2930
2931            // Apply changes
2932            for (raw_x, raw_y) in changes {
2933                let x = raw_x % w;
2934                let y = raw_y % h;
2935                new.set_raw(x, y, Cell::from_char('Z'));
2936            }
2937
2938            let full = BufferDiff::compute(&old, &new);
2939            let dirty = BufferDiff::compute_dirty(&old, &new);
2940
2941            prop_assert_eq!(
2942                full.changes(),
2943                dirty.changes(),
2944                "Large buffer mismatch: w={}, h={}, {}",
2945                w,
2946                h,
2947                super::span_diagnostics(&new)
2948            );
2949        }
2950    }
2951}