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