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