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