#![forbid(unsafe_code)]
use crate::buffer::{Buffer, DirtySpan};
use crate::cell::Cell;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffSkipHint {
FullDiff,
SkipDiff,
NarrowToRows(Vec<u16>),
}
impl DiffSkipHint {
#[must_use]
pub fn skips_work(&self) -> bool {
!matches!(self, Self::FullDiff)
}
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::FullDiff => "full-diff",
Self::SkipDiff => "skip-diff",
Self::NarrowToRows(_) => "narrow-to-rows",
}
}
}
const BLOCK_SIZE: usize = 4;
const _: () = assert!(
core::mem::size_of::<Cell>() * BLOCK_SIZE == 64,
"BLOCK_SIZE * Cell must equal 64-byte cache line"
);
const _: () = assert!(
core::mem::align_of::<Cell>() >= 16,
"Cell alignment must be >= 16 for SIMD access"
);
const ROW_BLOCK_SIZE: usize = 32;
#[repr(C, align(64))]
#[allow(dead_code)]
struct CacheLineBlock {
cells: [Cell; BLOCK_SIZE],
}
const _: () = assert!(
core::mem::size_of::<CacheLineBlock>() == 64,
"CacheLineBlock must be exactly 64 bytes"
);
#[cfg(test)]
fn span_diagnostics(buf: &Buffer) -> String {
let stats = buf.dirty_span_stats();
if !buf.dirty_span_config().enabled {
return format!("dirty spans disabled; stats={stats:?}");
}
let mut rows = Vec::new();
for y in 0..buf.height() {
let Some(row) = buf.dirty_span_row(y) else {
continue;
};
if row.is_full() {
rows.push(format!("{y}:full"));
continue;
}
if row.spans().is_empty() {
continue;
}
let spans = row
.spans()
.iter()
.map(|span| format!("[{}, {})", span.x0, span.x1))
.collect::<Vec<_>>()
.join(",");
rows.push(format!("{y}:{spans}"));
}
format!("stats={stats:?} rows=[{}]", rows.join(" | "))
}
#[inline(always)]
fn scan_row_changes_range(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
x_offset: u16,
changes: &mut Vec<(u16, u16)>,
) {
debug_assert_eq!(old_row.len(), new_row.len());
let len = old_row.len();
let blocks = len / BLOCK_SIZE;
let remainder = len % BLOCK_SIZE;
for block_idx in 0..blocks {
let base = block_idx * BLOCK_SIZE;
let base_x = x_offset + base as u16;
if cell_quad_bits_eq(old_row, new_row, base) {
continue;
}
for i in 0..BLOCK_SIZE {
if !old_row[base + i].bits_eq(&new_row[base + i]) {
changes.push((base_x + i as u16, y));
}
}
}
let rem_base = blocks * BLOCK_SIZE;
let rem_base_x = x_offset + rem_base as u16;
for i in 0..remainder {
if !old_row[rem_base + i].bits_eq(&new_row[rem_base + i]) {
changes.push((rem_base_x + i as u16, y));
}
}
}
#[inline(always)]
fn cell_quad_bits_eq(old_row: &[Cell], new_row: &[Cell], base: usize) -> bool {
old_row[base].bits_eq(&new_row[base])
& old_row[base + 1].bits_eq(&new_row[base + 1])
& old_row[base + 2].bits_eq(&new_row[base + 2])
& old_row[base + 3].bits_eq(&new_row[base + 3])
}
#[inline]
fn scan_row_changes(old_row: &[Cell], new_row: &[Cell], y: u16, changes: &mut Vec<(u16, u16)>) {
scan_row_changes_blockwise(old_row, new_row, y, changes);
}
#[inline]
fn scan_row_changes_blockwise(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
changes: &mut Vec<(u16, u16)>,
) {
debug_assert_eq!(old_row.len(), new_row.len());
let len = old_row.len();
let blocks = len / ROW_BLOCK_SIZE;
let remainder = len % ROW_BLOCK_SIZE;
for block_idx in 0..blocks {
let base = block_idx * ROW_BLOCK_SIZE;
let old_block = &old_row[base..base + ROW_BLOCK_SIZE];
let new_block = &new_row[base..base + ROW_BLOCK_SIZE];
scan_row_changes_range(old_block, new_block, y, base as u16, changes);
}
if remainder > 0 {
let base = blocks * ROW_BLOCK_SIZE;
let old_block = &old_row[base..base + remainder];
let new_block = &new_row[base..base + remainder];
scan_row_changes_range(old_block, new_block, y, base as u16, changes);
}
}
#[inline]
fn scan_row_changes_spans(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
spans: &[DirtySpan],
changes: &mut Vec<(u16, u16)>,
) {
for span in spans {
let start = span.x0 as usize;
let end = span.x1 as usize;
if start >= end || start >= old_row.len() {
continue;
}
let end = end.min(old_row.len());
let old_slice = &old_row[start..end];
let new_slice = &new_row[start..end];
if old_slice == new_slice {
continue;
}
scan_row_changes_range(old_slice, new_slice, y, span.x0, changes);
}
}
#[inline]
fn scan_row_changes_range_if_needed(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
start: usize,
end: usize,
changes: &mut Vec<(u16, u16)>,
) {
if start >= end || start >= old_row.len() {
return;
}
let end = end.min(old_row.len());
let old_slice = &old_row[start..end];
let new_slice = &new_row[start..end];
if old_slice == new_slice {
return;
}
scan_row_changes_range(old_slice, new_slice, y, start as u16, changes);
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn scan_row_tiles(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
width: usize,
tile_w: usize,
tiles_x: usize,
tile_row_base: usize,
dirty_tiles: &[bool],
changes: &mut Vec<(u16, u16)>,
) {
for tile_x in 0..tiles_x {
let tile_idx = tile_row_base + tile_x;
if !dirty_tiles[tile_idx] {
continue;
}
let start = tile_x * tile_w;
let end = ((tile_x + 1) * tile_w).min(width);
scan_row_changes_range_if_needed(old_row, new_row, y, start, end, changes);
}
}
#[inline]
#[allow(clippy::too_many_arguments)]
fn scan_row_tiles_spans(
old_row: &[Cell],
new_row: &[Cell],
y: u16,
width: usize,
tile_w: usize,
tiles_x: usize,
tile_row_base: usize,
dirty_tiles: &[bool],
spans: &[DirtySpan],
changes: &mut Vec<(u16, u16)>,
) {
if spans.is_empty() {
return;
}
let max_x = width.saturating_sub(1);
for span in spans {
let span_start = span.x0 as usize;
let span_end_exclusive = span.x1 as usize;
if span_start >= span_end_exclusive || span_start > max_x {
continue;
}
let span_end = span_end_exclusive.saturating_sub(1).min(max_x);
let tile_x_start = span_start / tile_w;
let tile_x_end = span_end / tile_w;
for tile_x in tile_x_start..=tile_x_end {
if tile_x >= tiles_x {
break;
}
let tile_idx = tile_row_base + tile_x;
if !dirty_tiles[tile_idx] {
continue;
}
let tile_start = tile_x * tile_w;
let tile_end = ((tile_x + 1) * tile_w).min(width);
let seg_start = span_start.max(tile_start);
let seg_end = span_end.min(tile_end.saturating_sub(1));
if seg_start > seg_end {
continue;
}
scan_row_changes_range_if_needed(old_row, new_row, y, seg_start, seg_end + 1, changes);
}
}
}
const TILE_SIZE_MIN: u16 = 8;
const TILE_SIZE_MAX: u16 = 64;
#[inline]
fn clamp_tile_size(value: u16) -> u16 {
value.clamp(TILE_SIZE_MIN, TILE_SIZE_MAX)
}
#[inline]
fn div_ceil_usize(n: usize, d: usize) -> usize {
debug_assert!(d > 0);
n.div_ceil(d)
}
#[inline]
fn accumulate_tile_counts_range(
tile_counts: &mut [u32],
dirty_row_bits: &[u8],
tile_row_base: usize,
tiles_x: usize,
tile_w: usize,
range: core::ops::Range<usize>,
scanned_cells: &mut usize,
) -> Result<(), ()> {
let start = range.start;
let end = range.end;
if start >= end || start >= dirty_row_bits.len() {
return Ok(());
}
let end = end.min(dirty_row_bits.len());
*scanned_cells = scanned_cells.saturating_add(end.saturating_sub(start));
let tile_x_start = start / tile_w;
let tile_x_end = (end - 1) / tile_w;
for tile_x in tile_x_start..=tile_x_end {
if tile_x >= tiles_x {
break;
}
let seg_start = start.max(tile_x * tile_w);
let seg_end = end.min((tile_x + 1) * tile_w);
let count = dirty_row_bits[seg_start..seg_end]
.iter()
.filter(|&&bit| bit != 0)
.count();
if count == 0 {
continue;
}
let tile_idx = tile_row_base + tile_x;
match tile_counts[tile_idx].checked_add(count as u32) {
Some(value) => tile_counts[tile_idx] = value,
None => return Err(()),
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct TileDiffConfig {
pub enabled: bool,
pub tile_w: u16,
pub tile_h: u16,
pub skip_clean_rows: bool,
pub min_cells_for_tiles: usize,
pub dense_cell_ratio: f64,
pub dense_tile_ratio: f64,
pub max_tiles: usize,
}
impl Default for TileDiffConfig {
fn default() -> Self {
Self {
enabled: true,
tile_w: 16,
tile_h: 8,
skip_clean_rows: true,
min_cells_for_tiles: 12_000,
dense_cell_ratio: 0.25,
dense_tile_ratio: 0.60,
max_tiles: 4096,
}
}
}
impl TileDiffConfig {
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn with_tile_size(mut self, tile_w: u16, tile_h: u16) -> Self {
self.tile_w = tile_w;
self.tile_h = tile_h;
self
}
#[must_use]
pub fn with_min_cells_for_tiles(mut self, min_cells: usize) -> Self {
self.min_cells_for_tiles = min_cells;
self
}
#[must_use]
pub fn with_skip_clean_rows(mut self, skip: bool) -> Self {
self.skip_clean_rows = skip;
self
}
#[must_use]
pub fn with_dense_cell_ratio(mut self, ratio: f64) -> Self {
self.dense_cell_ratio = ratio;
self
}
#[must_use]
pub fn with_dense_tile_ratio(mut self, ratio: f64) -> Self {
self.dense_tile_ratio = ratio;
self
}
#[must_use]
pub fn with_max_tiles(mut self, max_tiles: usize) -> Self {
self.max_tiles = max_tiles;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TileDiffFallback {
Disabled,
SmallScreen,
DirtyAll,
DenseCells,
DenseTiles,
TooManyTiles,
Overflow,
}
impl TileDiffFallback {
pub const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::SmallScreen => "small_screen",
Self::DirtyAll => "dirty_all",
Self::DenseCells => "dense_cells",
Self::DenseTiles => "dense_tiles",
Self::TooManyTiles => "too_many_tiles",
Self::Overflow => "overflow",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TileParams {
pub width: u16,
pub height: u16,
pub tile_w: u16,
pub tile_h: u16,
pub tiles_x: usize,
pub tiles_y: usize,
}
impl TileParams {
#[inline]
pub fn total_tiles(self) -> usize {
self.tiles_x * self.tiles_y
}
#[inline]
pub fn total_cells(self) -> usize {
self.width as usize * self.height as usize
}
}
#[derive(Debug, Clone, Copy)]
pub struct TileDiffStats {
pub width: u16,
pub height: u16,
pub tile_w: u16,
pub tile_h: u16,
pub tiles_x: usize,
pub tiles_y: usize,
pub total_tiles: usize,
pub dirty_cells: usize,
pub dirty_tiles: usize,
pub dirty_cell_ratio: f64,
pub dirty_tile_ratio: f64,
pub scanned_tiles: usize,
pub skipped_tiles: usize,
pub sat_build_cells: usize,
pub scan_cells_estimate: usize,
pub fallback: Option<TileDiffFallback>,
}
#[derive(Debug, Default, Clone)]
#[must_use]
pub struct TileDiffBuilder {
tile_counts: Vec<u32>,
sat: Vec<u32>,
dirty_tiles: Vec<bool>,
}
#[derive(Debug, Clone, Copy)]
pub struct TileDiffInput<'a> {
pub width: u16,
pub height: u16,
pub dirty_rows: &'a [bool],
pub dirty_bits: &'a [u8],
pub dirty_cells: usize,
pub dirty_all: bool,
}
#[derive(Debug, Clone)]
pub struct TileDiffPlan<'a> {
pub params: TileParams,
pub stats: TileDiffStats,
pub dirty_tiles: &'a [bool],
pub tile_counts: &'a [u32],
pub sat: &'a [u32],
}
#[derive(Debug, Clone)]
pub enum TileDiffBuild<'a> {
UseTiles(TileDiffPlan<'a>),
Fallback(TileDiffStats),
}
impl TileDiffBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn build<'a, 'b>(
&'a mut self,
config: &TileDiffConfig,
input: TileDiffInput<'b>,
) -> TileDiffBuild<'a> {
let TileDiffInput {
width,
height,
dirty_rows,
dirty_bits,
dirty_cells,
dirty_all,
} = input;
let tile_w = clamp_tile_size(config.tile_w);
let tile_h = clamp_tile_size(config.tile_h);
let width_usize = width as usize;
let height_usize = height as usize;
let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
let total_tiles = tiles_x * tiles_y;
let total_cells = width_usize * height_usize;
let dirty_cell_ratio = if total_cells == 0 {
0.0
} else {
dirty_cells as f64 / total_cells as f64
};
let mut stats = TileDiffStats {
width,
height,
tile_w,
tile_h,
tiles_x,
tiles_y,
total_tiles,
dirty_cells,
dirty_tiles: 0,
dirty_cell_ratio,
dirty_tile_ratio: 0.0,
scanned_tiles: 0,
skipped_tiles: total_tiles,
sat_build_cells: 0,
scan_cells_estimate: 0,
fallback: None,
};
if !config.enabled {
stats.fallback = Some(TileDiffFallback::Disabled);
return TileDiffBuild::Fallback(stats);
}
if total_cells < config.min_cells_for_tiles {
stats.fallback = Some(TileDiffFallback::SmallScreen);
return TileDiffBuild::Fallback(stats);
}
if dirty_all {
stats.fallback = Some(TileDiffFallback::DirtyAll);
return TileDiffBuild::Fallback(stats);
}
if dirty_cell_ratio >= config.dense_cell_ratio {
stats.fallback = Some(TileDiffFallback::DenseCells);
return TileDiffBuild::Fallback(stats);
}
if total_tiles > config.max_tiles {
stats.fallback = Some(TileDiffFallback::TooManyTiles);
return TileDiffBuild::Fallback(stats);
}
debug_assert_eq!(dirty_bits.len(), total_cells);
if dirty_bits.len() < total_cells {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
self.tile_counts.resize(total_tiles, 0);
self.tile_counts.fill(0);
self.dirty_tiles.resize(total_tiles, false);
self.dirty_tiles.fill(false);
let tile_w_usize = tile_w as usize;
let tile_h_usize = tile_h as usize;
let mut overflow = false;
let mut scanned_cells = 0usize;
debug_assert_eq!(dirty_rows.len(), height_usize);
for y in 0..height_usize {
let row_dirty = if config.skip_clean_rows {
dirty_rows.get(y).copied().unwrap_or(true)
} else {
true
};
if !row_dirty {
continue;
}
scanned_cells = scanned_cells.saturating_add(width_usize);
let row_start = y * width_usize;
let tile_y = y / tile_h_usize;
for x in 0..width_usize {
let idx = row_start + x;
if dirty_bits[idx] == 0 {
continue;
}
let tile_x = x / tile_w_usize;
let tile_idx = tile_y * tiles_x + tile_x;
match self.tile_counts[tile_idx].checked_add(1) {
Some(value) => self.tile_counts[tile_idx] = value,
None => {
overflow = true;
break;
}
}
}
if overflow {
break;
}
}
if overflow {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
let mut dirty_tiles = 0usize;
for (idx, count) in self.tile_counts.iter().enumerate() {
if *count > 0 {
self.dirty_tiles[idx] = true;
dirty_tiles += 1;
}
}
stats.dirty_tiles = dirty_tiles;
stats.dirty_tile_ratio = if total_tiles == 0 {
0.0
} else {
dirty_tiles as f64 / total_tiles as f64
};
stats.scanned_tiles = dirty_tiles;
stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
stats.sat_build_cells = scanned_cells;
stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
if stats.dirty_tile_ratio >= config.dense_tile_ratio {
stats.fallback = Some(TileDiffFallback::DenseTiles);
return TileDiffBuild::Fallback(stats);
}
let sat_w = tiles_x + 1;
let sat_h = tiles_y + 1;
let sat_len = sat_w * sat_h;
self.sat.resize(sat_len, 0);
self.sat.fill(0);
for ty in 0..tiles_y {
let row_base = (ty + 1) * sat_w;
let prev_base = ty * sat_w;
for tx in 0..tiles_x {
let count = self.tile_counts[ty * tiles_x + tx] as u64;
let above = self.sat[prev_base + tx + 1] as u64;
let left = self.sat[row_base + tx] as u64;
let diag = self.sat[prev_base + tx] as u64;
let value = count + above + left - diag;
if value > u32::MAX as u64 {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
self.sat[row_base + tx + 1] = value as u32;
}
}
let params = TileParams {
width,
height,
tile_w,
tile_h,
tiles_x,
tiles_y,
};
TileDiffBuild::UseTiles(TileDiffPlan {
params,
stats,
dirty_tiles: &self.dirty_tiles,
tile_counts: &self.tile_counts,
sat: &self.sat,
})
}
fn build_from_buffer<'a>(
&'a mut self,
config: &TileDiffConfig,
buffer: &Buffer,
) -> TileDiffBuild<'a> {
let width = buffer.width();
let height = buffer.height();
let dirty_rows = buffer.dirty_rows();
let dirty_bits = buffer.dirty_bits();
let dirty_cells = buffer.dirty_cell_count();
let dirty_all = buffer.dirty_all();
let tile_w = clamp_tile_size(config.tile_w);
let tile_h = clamp_tile_size(config.tile_h);
let width_usize = width as usize;
let height_usize = height as usize;
let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
let total_tiles = tiles_x * tiles_y;
let total_cells = width_usize * height_usize;
let dirty_cell_ratio = if total_cells == 0 {
0.0
} else {
dirty_cells as f64 / total_cells as f64
};
let mut stats = TileDiffStats {
width,
height,
tile_w,
tile_h,
tiles_x,
tiles_y,
total_tiles,
dirty_cells,
dirty_tiles: 0,
dirty_cell_ratio,
dirty_tile_ratio: 0.0,
scanned_tiles: 0,
skipped_tiles: total_tiles,
sat_build_cells: 0,
scan_cells_estimate: 0,
fallback: None,
};
if !config.enabled {
stats.fallback = Some(TileDiffFallback::Disabled);
return TileDiffBuild::Fallback(stats);
}
if total_cells < config.min_cells_for_tiles {
stats.fallback = Some(TileDiffFallback::SmallScreen);
return TileDiffBuild::Fallback(stats);
}
if dirty_all {
stats.fallback = Some(TileDiffFallback::DirtyAll);
return TileDiffBuild::Fallback(stats);
}
if dirty_cell_ratio >= config.dense_cell_ratio {
stats.fallback = Some(TileDiffFallback::DenseCells);
return TileDiffBuild::Fallback(stats);
}
if total_tiles > config.max_tiles {
stats.fallback = Some(TileDiffFallback::TooManyTiles);
return TileDiffBuild::Fallback(stats);
}
debug_assert_eq!(dirty_bits.len(), total_cells);
if dirty_bits.len() < total_cells {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
self.tile_counts.resize(total_tiles, 0);
self.tile_counts.fill(0);
self.dirty_tiles.resize(total_tiles, false);
self.dirty_tiles.fill(false);
let tile_w_usize = tile_w as usize;
let tile_h_usize = tile_h as usize;
let mut overflow = false;
let mut scanned_cells = 0usize;
debug_assert_eq!(dirty_rows.len(), height_usize);
for y in 0..height_usize {
let row_dirty = if config.skip_clean_rows {
dirty_rows.get(y).copied().unwrap_or(true)
} else {
true
};
if !row_dirty {
continue;
}
let row_start = y * width_usize;
let row_bits = &dirty_bits[row_start..row_start + width_usize];
let tile_y = y / tile_h_usize;
let tile_row_base = tile_y * tiles_x;
let row_result = match buffer.dirty_span_row(y as u16) {
Some(span_row) if !span_row.is_full() => {
let spans = span_row.spans();
if spans.is_empty() {
accumulate_tile_counts_range(
&mut self.tile_counts,
row_bits,
tile_row_base,
tiles_x,
tile_w_usize,
0..width_usize,
&mut scanned_cells,
)
} else {
let mut result = Ok(());
for span in spans {
result = accumulate_tile_counts_range(
&mut self.tile_counts,
row_bits,
tile_row_base,
tiles_x,
tile_w_usize,
span.x0 as usize..span.x1 as usize,
&mut scanned_cells,
);
if result.is_err() {
break;
}
}
result
}
}
_ => accumulate_tile_counts_range(
&mut self.tile_counts,
row_bits,
tile_row_base,
tiles_x,
tile_w_usize,
0..width_usize,
&mut scanned_cells,
),
};
if row_result.is_err() {
overflow = true;
break;
}
}
if overflow {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
let mut dirty_tiles = 0usize;
for (idx, count) in self.tile_counts.iter().enumerate() {
if *count > 0 {
self.dirty_tiles[idx] = true;
dirty_tiles += 1;
}
}
stats.dirty_tiles = dirty_tiles;
stats.dirty_tile_ratio = if total_tiles == 0 {
0.0
} else {
dirty_tiles as f64 / total_tiles as f64
};
stats.scanned_tiles = dirty_tiles;
stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
stats.sat_build_cells = scanned_cells;
stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
if stats.dirty_tile_ratio >= config.dense_tile_ratio {
stats.fallback = Some(TileDiffFallback::DenseTiles);
return TileDiffBuild::Fallback(stats);
}
let sat_w = tiles_x + 1;
let sat_h = tiles_y + 1;
let sat_len = sat_w * sat_h;
self.sat.resize(sat_len, 0);
self.sat.fill(0);
for ty in 0..tiles_y {
let row_base = (ty + 1) * sat_w;
let prev_base = ty * sat_w;
for tx in 0..tiles_x {
let count = self.tile_counts[ty * tiles_x + tx] as u64;
let above = self.sat[prev_base + tx + 1] as u64;
let left = self.sat[row_base + tx] as u64;
let diag = self.sat[prev_base + tx] as u64;
let value = count + above + left - diag;
if value > u32::MAX as u64 {
stats.fallback = Some(TileDiffFallback::Overflow);
return TileDiffBuild::Fallback(stats);
}
self.sat[row_base + tx + 1] = value as u32;
}
}
let params = TileParams {
width,
height,
tile_w,
tile_h,
tiles_x,
tiles_y,
};
TileDiffBuild::UseTiles(TileDiffPlan {
params,
stats,
dirty_tiles: &self.dirty_tiles,
tile_counts: &self.tile_counts,
sat: &self.sat,
})
}
}
#[inline]
fn reserve_changes_capacity(width: u16, height: u16, changes: &mut Vec<(u16, u16)>) {
let estimated_changes = (width as usize * height as usize) / 20;
let additional = estimated_changes.saturating_sub(changes.len());
if additional > 0 {
changes.reserve(additional);
}
}
fn compute_changes(old: &Buffer, new: &Buffer, changes: &mut Vec<(u16, u16)>) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!("diff_compute", width = old.width(), height = old.height());
#[cfg(feature = "tracing")]
let _guard = _span.enter();
assert_eq!(old.width(), new.width(), "buffer widths must match");
assert_eq!(old.height(), new.height(), "buffer heights must match");
let width = old.width();
let height = old.height();
let w = width as usize;
changes.clear();
reserve_changes_capacity(width, height, changes);
let old_cells = old.cells();
let new_cells = new.cells();
for y in 0..height {
let row_start = y as usize * w;
let old_row = &old_cells[row_start..row_start + w];
let new_row = &new_cells[row_start..row_start + w];
scan_row_changes(old_row, new_row, y, changes);
}
#[cfg(feature = "tracing")]
tracing::trace!(changes = changes.len(), "diff computed");
}
fn compute_dirty_changes(
old: &Buffer,
new: &Buffer,
changes: &mut Vec<(u16, u16)>,
tile_builder: &mut TileDiffBuilder,
tile_config: &TileDiffConfig,
tile_stats_out: &mut Option<TileDiffStats>,
) {
assert_eq!(old.width(), new.width(), "buffer widths must match");
assert_eq!(old.height(), new.height(), "buffer heights must match");
let width = old.width();
let height = old.height();
let w = width as usize;
changes.clear();
reserve_changes_capacity(width, height, changes);
let old_cells = old.cells();
let new_cells = new.cells();
let dirty = new.dirty_rows();
*tile_stats_out = None;
let tile_build = tile_builder.build_from_buffer(tile_config, new);
if let TileDiffBuild::UseTiles(plan) = tile_build {
*tile_stats_out = Some(plan.stats);
let tile_w = plan.params.tile_w as usize;
let tile_h = plan.params.tile_h as usize;
let tiles_x = plan.params.tiles_x;
let dirty_tiles = plan.dirty_tiles;
for y in 0..height {
if !dirty[y as usize] {
continue;
}
let row_start = y as usize * w;
let old_row = &old_cells[row_start..row_start + w];
let new_row = &new_cells[row_start..row_start + w];
if old_row == new_row {
continue;
}
let tile_y = y as usize / tile_h;
let tile_row_base = tile_y * tiles_x;
debug_assert!(tile_row_base + tiles_x <= dirty_tiles.len());
let span_row = new.dirty_span_row(y);
if let Some(span_row) = span_row {
if span_row.is_full() {
scan_row_tiles(
old_row,
new_row,
y,
w,
tile_w,
tiles_x,
tile_row_base,
dirty_tiles,
changes,
);
continue;
}
let spans = span_row.spans();
if spans.is_empty() {
scan_row_tiles(
old_row,
new_row,
y,
w,
tile_w,
tiles_x,
tile_row_base,
dirty_tiles,
changes,
);
continue;
}
scan_row_tiles_spans(
old_row,
new_row,
y,
w,
tile_w,
tiles_x,
tile_row_base,
dirty_tiles,
spans,
changes,
);
} else {
scan_row_tiles(
old_row,
new_row,
y,
w,
tile_w,
tiles_x,
tile_row_base,
dirty_tiles,
changes,
);
}
}
return;
}
if let TileDiffBuild::Fallback(stats) = tile_build {
*tile_stats_out = Some(stats);
}
for y in 0..height {
if !dirty[y as usize] {
continue;
}
let row_start = y as usize * w;
let old_row = &old_cells[row_start..row_start + w];
let new_row = &new_cells[row_start..row_start + w];
if old_row == new_row {
continue;
}
let span_row = new.dirty_span_row(y);
if let Some(span_row) = span_row {
if span_row.is_full() {
scan_row_changes(old_row, new_row, y, changes);
continue;
}
let spans = span_row.spans();
if spans.is_empty() {
scan_row_changes(old_row, new_row, y, changes);
continue;
}
scan_row_changes_spans(old_row, new_row, y, spans, changes);
} else {
scan_row_changes(old_row, new_row, y, changes);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChangeRun {
pub y: u16,
pub x0: u16,
pub x1: u16,
}
impl ChangeRun {
#[inline]
pub const fn new(y: u16, x0: u16, x1: u16) -> Self {
debug_assert!(x0 <= x1);
Self { y, x0, x1 }
}
#[inline]
pub const fn len(&self) -> usize {
self.x1.saturating_sub(self.x0) as usize + 1
}
#[inline]
pub const fn is_empty(&self) -> bool {
self.x1 < self.x0
}
}
#[derive(Debug, Clone, Default)]
pub struct BufferDiff {
changes: Vec<(u16, u16)>,
tile_builder: TileDiffBuilder,
tile_config: TileDiffConfig,
last_tile_stats: Option<TileDiffStats>,
}
impl BufferDiff {
pub fn new() -> Self {
Self {
changes: Vec::new(),
tile_builder: TileDiffBuilder::default(),
tile_config: TileDiffConfig::default(),
last_tile_stats: None,
}
}
pub fn with_capacity(capacity: usize) -> Self {
let mut diff = Self::new();
diff.changes = Vec::with_capacity(capacity);
diff
}
pub fn full(width: u16, height: u16) -> Self {
if width == 0 || height == 0 {
return Self::new();
}
let total = width as usize * height as usize;
let mut changes = Vec::with_capacity(total);
for y in 0..height {
for x in 0..width {
changes.push((x, y));
}
}
let mut diff = Self::new();
diff.changes = changes;
diff
}
pub fn compute(old: &Buffer, new: &Buffer) -> Self {
let mut diff = Self::new();
diff.compute_into(old, new);
diff
}
pub fn compute_into(&mut self, old: &Buffer, new: &Buffer) {
self.last_tile_stats = None;
compute_changes(old, new, &mut self.changes);
}
pub fn compute_dirty(old: &Buffer, new: &Buffer) -> Self {
let mut diff = Self::new();
diff.compute_dirty_into(old, new);
diff
}
pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
compute_dirty_changes(
old,
new,
&mut self.changes,
&mut self.tile_builder,
&self.tile_config,
&mut self.last_tile_stats,
);
}
pub fn compute_certified_into(&mut self, old: &Buffer, new: &Buffer, hint: DiffSkipHint) {
match hint {
DiffSkipHint::FullDiff => {
self.compute_dirty_into(old, new);
}
DiffSkipHint::SkipDiff => {
self.changes.clear();
self.last_tile_stats = None;
#[cfg(feature = "tracing")]
tracing::debug!(
event = "diff_skip_certified",
hint = "skip_diff",
changes = 0,
);
}
DiffSkipHint::NarrowToRows(ref rows) => {
self.changes.clear();
self.last_tile_stats = None;
let w = old.width();
let h = old.height();
debug_assert_eq!(w, new.width());
debug_assert_eq!(h, new.height());
let old_cells = old.cells();
let new_cells = new.cells();
let stride = w as usize;
for &row in rows {
if row >= h {
continue;
}
let start = row as usize * stride;
let end = start + stride;
if end > old_cells.len() || end > new_cells.len() {
continue;
}
let old_row = &old_cells[start..end];
let new_row = &new_cells[start..end];
if old_row == new_row {
continue;
}
for (x, (o, n)) in old_row.iter().zip(new_row.iter()).enumerate() {
if !o.bits_eq(n) {
self.changes.push((x as u16, row));
}
}
}
#[cfg(feature = "tracing")]
tracing::debug!(
event = "diff_narrow_certified",
hint = "narrow_to_rows",
rows_checked = rows.len(),
changes = self.changes.len(),
);
}
}
}
pub fn fill_full(&mut self, width: u16, height: u16) {
self.changes.clear();
let total = width as usize * height as usize;
if self.changes.capacity() < total {
self.changes.reserve(total - self.changes.len());
}
for y in 0..height {
for x in 0..width {
self.changes.push((x, y));
}
}
}
#[inline]
pub fn len(&self) -> usize {
self.changes.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
#[inline]
pub fn changes(&self) -> &[(u16, u16)] {
&self.changes
}
#[inline]
pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
self.last_tile_stats
}
#[inline]
pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
&mut self.tile_config
}
pub fn runs(&self) -> Vec<ChangeRun> {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
#[cfg(feature = "tracing")]
let _guard = _span.enter();
if self.changes.is_empty() {
return Vec::new();
}
let sorted = &self.changes;
let len = sorted.len();
let mut runs = Vec::with_capacity(len);
let mut i = 0;
while i < len {
let (x0, y) = sorted[i];
let mut x1 = x0;
i += 1;
while i < len {
let (x, yy) = sorted[i];
if yy != y || x != x1.saturating_add(1) {
break;
}
x1 = x;
i += 1;
}
runs.push(ChangeRun::new(y, x0, x1));
}
#[cfg(feature = "tracing")]
tracing::trace!(run_count = runs.len(), "runs coalesced");
runs
}
pub fn runs_into(&self, out: &mut Vec<ChangeRun>) {
out.clear();
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!("diff_runs_into", changes = self.changes.len());
#[cfg(feature = "tracing")]
let _guard = _span.enter();
if self.changes.is_empty() {
return;
}
let sorted = &self.changes;
let len = sorted.len();
let mut i = 0;
while i < len {
let (x0, y) = sorted[i];
let mut x1 = x0;
i += 1;
while i < len {
let (x, yy) = sorted[i];
if yy != y || x != x1.saturating_add(1) {
break;
}
x1 = x;
i += 1;
}
out.push(ChangeRun::new(y, x0, x1));
}
#[cfg(feature = "tracing")]
tracing::trace!(run_count = out.len(), "runs coalesced (reuse)");
}
#[inline]
pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
self.changes.iter().copied()
}
pub fn clear(&mut self) {
self.changes.clear();
}
}
#[cfg(test)]
mod tests {
fn is_coverage_run() -> bool {
if let Ok(value) = std::env::var("FTUI_COVERAGE") {
let value = value.to_ascii_lowercase();
if matches!(value.as_str(), "1" | "true" | "yes") {
return true;
}
if matches!(value.as_str(), "0" | "false" | "no") {
return false;
}
}
std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
}
use super::*;
use crate::cell::{Cell, PackedRgba};
#[test]
fn scan_row_changes_range_reports_late_change_after_equal_quad() {
let old = vec![Cell::default(); 8];
let mut new = old.clone();
let mut changes = Vec::new();
new[7] = Cell::from_char('X');
scan_row_changes_range(&old, &new, 4, 10, &mut changes);
assert_eq!(changes, vec![(17, 4)]);
}
#[test]
fn empty_diff_when_buffers_identical() {
let buf1 = Buffer::new(10, 10);
let buf2 = Buffer::new(10, 10);
let diff = BufferDiff::compute(&buf1, &buf2);
assert!(diff.is_empty());
assert_eq!(diff.len(), 0);
}
#[test]
fn full_diff_marks_all_cells() {
let diff = BufferDiff::full(3, 2);
assert_eq!(diff.len(), 6);
assert_eq!(diff.changes()[0], (0, 0));
assert_eq!(diff.changes()[5], (2, 1));
}
#[test]
fn single_cell_change_detected() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(5, 5, Cell::from_char('X'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(5, 5)]);
}
#[test]
fn dirty_row_false_positive_skipped() {
let old = Buffer::new(8, 2);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(2, 0, Cell::default());
let diff = BufferDiff::compute_dirty(&old, &new);
assert!(
diff.is_empty(),
"dirty row with no changes should be skipped"
);
}
#[test]
fn multiple_scattered_changes_detected() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(9, 9, Cell::from_char('B'));
new.set_raw(5, 3, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 3);
let changes = diff.changes();
assert!(changes.contains(&(0, 0)));
assert!(changes.contains(&(9, 9)));
assert!(changes.contains(&(5, 3)));
}
#[test]
fn runs_coalesces_adjacent_cells() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(3, 5, Cell::from_char('A'));
new.set_raw(4, 5, Cell::from_char('B'));
new.set_raw(5, 5, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].y, 5);
assert_eq!(runs[0].x0, 3);
assert_eq!(runs[0].x1, 5);
assert_eq!(runs[0].len(), 3);
}
#[test]
fn runs_handles_gaps_correctly() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 0, Cell::from_char('B'));
new.set_raw(3, 0, Cell::from_char('C'));
new.set_raw(4, 0, Cell::from_char('D'));
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].y, 0);
assert_eq!(runs[0].x0, 0);
assert_eq!(runs[0].x1, 1);
assert_eq!(runs[1].y, 0);
assert_eq!(runs[1].x0, 3);
assert_eq!(runs[1].x1, 4);
}
#[test]
fn runs_handles_max_column_without_overflow() {
let mut diff = BufferDiff::new();
diff.changes = vec![(u16::MAX, 0)];
let runs = diff.runs();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
}
#[test]
fn runs_handles_multiple_rows() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 0, Cell::from_char('B'));
new.set_raw(5, 2, Cell::from_char('C'));
new.set_raw(0, 5, Cell::from_char('D'));
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].y, 0);
assert_eq!(runs[0].x0, 0);
assert_eq!(runs[0].x1, 1);
assert_eq!(runs[1].y, 2);
assert_eq!(runs[1].x0, 5);
assert_eq!(runs[1].x1, 5);
assert_eq!(runs[2].y, 5);
assert_eq!(runs[2].x0, 0);
assert_eq!(runs[2].x1, 0);
}
#[test]
fn empty_runs_from_empty_diff() {
let diff = BufferDiff::new();
let runs = diff.runs();
assert!(runs.is_empty());
}
#[test]
fn change_run_len() {
let run = ChangeRun::new(0, 5, 10);
assert_eq!(run.len(), 6);
let single = ChangeRun::new(0, 5, 5);
assert_eq!(single.len(), 1);
}
#[test]
fn color_changes_detected() {
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
}
#[test]
fn diff_iter() {
let old = Buffer::new(5, 5);
let mut new = Buffer::new(5, 5);
new.set_raw(1, 1, Cell::from_char('X'));
new.set_raw(2, 2, Cell::from_char('Y'));
let diff = BufferDiff::compute(&old, &new);
let positions: Vec<_> = diff.iter().collect();
assert_eq!(positions.len(), 2);
assert!(positions.contains(&(1, 1)));
assert!(positions.contains(&(2, 2)));
}
#[test]
fn diff_clear() {
let old = Buffer::new(5, 5);
let mut new = Buffer::new(5, 5);
new.set_raw(1, 1, Cell::from_char('X'));
let mut diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
diff.clear();
assert!(diff.is_empty());
}
#[test]
fn with_capacity() {
let diff = BufferDiff::with_capacity(100);
assert!(diff.is_empty());
}
#[test]
fn full_buffer_change() {
let old = Buffer::new(5, 5);
let mut new = Buffer::new(5, 5);
for y in 0..5 {
for x in 0..5 {
new.set_raw(x, y, Cell::from_char('#'));
}
}
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 25);
let runs = diff.runs();
assert_eq!(runs.len(), 5);
for (i, run) in runs.iter().enumerate() {
assert_eq!(run.y, i as u16);
assert_eq!(run.x0, 0);
assert_eq!(run.x1, 4);
assert_eq!(run.len(), 5);
}
}
#[test]
fn row_major_order_preserved() {
let old = Buffer::new(3, 3);
let mut new = Buffer::new(3, 3);
new.set_raw(2, 2, Cell::from_char('C'));
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 1, Cell::from_char('B'));
let diff = BufferDiff::compute(&old, &new);
let changes = diff.changes();
assert_eq!(changes[0], (0, 0));
assert_eq!(changes[1], (1, 1));
assert_eq!(changes[2], (2, 2));
}
#[test]
fn blockwise_scan_preserves_sparse_row_changes() {
let old = Buffer::new(64, 2);
let mut new = old.clone();
new.set_raw(1, 0, Cell::from_char('A'));
new.set_raw(33, 0, Cell::from_char('B'));
new.set_raw(62, 1, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
}
#[test]
fn rows_with_no_changes_are_skipped() {
let old = Buffer::new(4, 3);
let mut new = old.clone();
new.set_raw(1, 1, Cell::from_char('X'));
new.set_raw(3, 1, Cell::from_char('Y'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 2);
assert!(diff.changes().iter().all(|&(_, y)| y == 1));
}
#[test]
fn clear_retains_capacity_for_reuse() {
let mut diff = BufferDiff::with_capacity(16);
diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
let capacity = diff.changes.capacity();
diff.clear();
assert!(diff.is_empty());
assert!(diff.changes.capacity() >= capacity);
}
#[test]
#[should_panic(expected = "buffer widths must match")]
fn compute_panics_on_width_mismatch() {
let old = Buffer::new(5, 5);
let new = Buffer::new(4, 5);
let _ = BufferDiff::compute(&old, &new);
}
#[test]
fn block_scan_alignment_exact_block() {
let old = Buffer::new(4, 1);
let mut new = Buffer::new(4, 1);
new.set_raw(2, 0, Cell::from_char('X'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(2, 0)]);
}
#[test]
fn block_scan_alignment_remainder() {
let old = Buffer::new(7, 1);
let mut new = Buffer::new(7, 1);
new.set_raw(1, 0, Cell::from_char('A'));
new.set_raw(5, 0, Cell::from_char('B'));
new.set_raw(6, 0, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 3);
assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
}
#[test]
fn block_scan_single_cell_row() {
let old = Buffer::new(1, 1);
let mut new = Buffer::new(1, 1);
new.set_raw(0, 0, Cell::from_char('X'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(0, 0)]);
}
#[test]
fn block_scan_two_cell_row() {
let old = Buffer::new(2, 1);
let mut new = Buffer::new(2, 1);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 0, Cell::from_char('B'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 2);
assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
}
#[test]
fn block_scan_three_cell_row() {
let old = Buffer::new(3, 1);
let mut new = Buffer::new(3, 1);
new.set_raw(2, 0, Cell::from_char('X'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(2, 0)]);
}
#[test]
fn block_scan_multiple_blocks_sparse() {
let old = Buffer::new(80, 1);
let mut new = Buffer::new(80, 1);
for block in (0..20).step_by(2) {
let x = (block * 4 + 1) as u16;
new.set_raw(x, 0, Cell::from_char('X'));
}
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 10);
assert_eq!(
diff.changes(),
&[
(1, 0),
(9, 0),
(17, 0),
(25, 0),
(33, 0),
(41, 0),
(49, 0),
(57, 0),
(65, 0),
(73, 0)
]
);
}
#[test]
fn block_scan_full_block_unchanged_skip() {
let old = Buffer::new(20, 1);
let mut new = Buffer::new(20, 1);
new.set_raw(19, 0, Cell::from_char('Z'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(19, 0)]);
}
#[test]
fn block_scan_wide_row_all_changed() {
let old = Buffer::new(120, 1);
let mut new = Buffer::new(120, 1);
for x in 0..120 {
new.set_raw(x, 0, Cell::from_char('#'));
}
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 120);
}
#[test]
fn perf_block_scan_vs_scalar_baseline() {
use std::time::Instant;
let width = 200u16;
let height = 50u16;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for i in 0..1000 {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 11 + 5) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
new.set_raw(x, y, Cell::from_char(ch));
}
let iterations = 1000u32;
let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(50)
.clamp(1, iterations as usize);
let iters_per_sample = (iterations / samples as u32).max(1) as u64;
let mut times_us = Vec::with_capacity(samples);
let mut last_checksum = 0u64;
for _ in 0..samples {
let start = Instant::now();
for _ in 0..iters_per_sample {
let diff = BufferDiff::compute(&old, &new);
assert!(!diff.is_empty());
last_checksum = fnv1a_hash(diff.changes());
}
let elapsed = start.elapsed();
let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
times_us.push(per_iter);
}
times_us.sort_unstable();
let len = times_us.len();
let p50 = times_us[len / 2];
let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
let mean = times_us
.iter()
.copied()
.map(|value| value as f64)
.sum::<f64>()
/ len as f64;
let variance = times_us
.iter()
.map(|value| {
let delta = *value as f64 - mean;
delta * delta
})
.sum::<f64>()
/ len as f64;
eprintln!(
"{{\"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}\"}}",
width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
);
#[allow(unexpected_cfgs)]
let p50_budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
#[allow(unexpected_cfgs)]
let p95_budget_us = if is_coverage_run() {
10_000u64
} else {
2_500u64
};
assert!(
p50 <= p50_budget_us,
"Diff too slow: p50={p50}µs (budget {p50_budget_us}µs) for {width}x{height}"
);
assert!(
p95 <= p95_budget_us,
"Diff tail too slow: p95={p95}µs (budget {p95_budget_us}µs) for {width}x{height}"
);
}
#[test]
fn unit_run_coalescing_invariants() {
let old = Buffer::new(80, 24);
let mut new = Buffer::new(80, 24);
for x in 0..=2 {
new.set_raw(x, 0, Cell::from_char('A'));
}
for x in 10..=12 {
new.set_raw(x, 0, Cell::from_char('B'));
}
for x in 40..=45 {
new.set_raw(x, 5, Cell::from_char('C'));
}
new.set_raw(79, 23, Cell::from_char('Z'));
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
for w in runs.windows(2) {
assert!(
(w[0].y, w[0].x0) < (w[1].y, w[1].x0),
"runs must be sorted: {:?} should precede {:?}",
w[0],
w[1]
);
}
let total_cells: usize = runs.iter().map(|r| r.len()).sum();
assert_eq!(
total_cells,
diff.len(),
"runs must cover all changes exactly"
);
for w in runs.windows(2) {
if w[0].y == w[1].y {
assert!(
w[1].x0 > w[0].x1.saturating_add(1),
"adjacent runs on same row should be merged: {:?} and {:?}",
w[0],
w[1]
);
}
}
assert_eq!(runs.len(), 4);
assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
}
fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &(x, y) in data {
for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
}
hash
}
fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
let mut buf = Buffer::new(width, height);
let mut rng = seed;
for x in 0..width {
buf.set_raw(x, 0, Cell::from_char('='));
}
for x in 0..width {
buf.set_raw(x, height - 1, Cell::from_char('-'));
}
let count = (width as u64 * height as u64 / 10).max(5);
for _ in 0..count {
rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let x = ((rng >> 16) as u16) % width;
rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let y = ((rng >> 16) as u16) % height;
rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
buf.set_raw(x, y, Cell::from_char(ch));
}
buf
}
#[test]
fn golden_diff_80x24() {
let old = Buffer::new(80, 24);
let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
let diff = BufferDiff::compute(&old, &new);
let checksum = fnv1a_hash(diff.changes());
let diff2 = BufferDiff::compute(&old, &new);
assert_eq!(
fnv1a_hash(diff2.changes()),
checksum,
"diff must be deterministic"
);
assert!(
diff.len() >= 160,
"80x24 golden scene should have at least 160 changes (header+status), got {}",
diff.len()
);
let runs = diff.runs();
assert_eq!(runs[0].y, 0, "first run should be header row");
assert_eq!(runs[0].x0, 0);
assert_eq!(runs[0].x1, 79);
assert!(
runs.last().unwrap().y == 23,
"last row should contain status bar"
);
}
#[test]
fn golden_diff_120x40() {
let old = Buffer::new(120, 40);
let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
let diff = BufferDiff::compute(&old, &new);
let checksum = fnv1a_hash(diff.changes());
let diff2 = BufferDiff::compute(&old, &new);
assert_eq!(fnv1a_hash(diff2.changes()), checksum);
assert!(
diff.len() >= 240,
"120x40 golden scene should have >=240 changes, got {}",
diff.len()
);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(
fnv1a_hash(dirty.changes()),
checksum,
"dirty diff must produce identical changes"
);
}
#[test]
fn golden_sparse_update() {
let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
let mut new = old.clone();
new.set_raw(10, 5, Cell::from_char('!'));
new.set_raw(11, 5, Cell::from_char('@'));
new.set_raw(40, 12, Cell::from_char('#'));
new.set_raw(70, 20, Cell::from_char('$'));
new.set_raw(0, 23, Cell::from_char('%'));
let diff = BufferDiff::compute(&old, &new);
let checksum = fnv1a_hash(diff.changes());
let diff2 = BufferDiff::compute(&old, &new);
assert_eq!(fnv1a_hash(diff2.changes()), checksum);
assert!(
diff.len() <= 5,
"sparse update should have <=5 changes, got {}",
diff.len()
);
assert!(
diff.len() >= 3,
"sparse update should have >=3 changes, got {}",
diff.len()
);
}
#[test]
fn e2e_random_scene_replay() {
let width = 80u16;
let height = 24u16;
let base_seed: u64 = 0x5C3E_E3E1_A442u64;
let mut checksums = Vec::new();
for frame in 0..10u64 {
let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let old = build_golden_scene(width, height, seed);
let new = build_golden_scene(width, height, seed.wrapping_add(1));
let diff = BufferDiff::compute(&old, &new);
let dirty_diff = BufferDiff::compute_dirty(&old, &new);
assert_eq!(
diff.changes(),
dirty_diff.changes(),
"frame {frame}: dirty diff must match full diff"
);
checksums.push(fnv1a_hash(diff.changes()));
}
for frame in 0..10u64 {
let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let old = build_golden_scene(width, height, seed);
let new = build_golden_scene(width, height, seed.wrapping_add(1));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(
fnv1a_hash(diff.changes()),
checksums[frame as usize],
"frame {frame}: checksum mismatch on replay"
);
}
}
#[test]
fn perf_diff_microbench() {
use std::time::Instant;
let scenarios: &[(u16, u16, &str, u64)] = &[
(80, 24, "full_frame", 0xBE4C_0001u64),
(80, 24, "sparse_update", 0xBE4C_0002u64),
(120, 40, "full_frame", 0xBE4C_0003u64),
(120, 40, "sparse_update", 0xBE4C_0004u64),
];
let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(50u32);
for &(width, height, scene_type, seed) in scenarios {
let old = Buffer::new(width, height);
let new = match scene_type {
"full_frame" => build_golden_scene(width, height, seed),
"sparse_update" => {
let mut buf = old.clone();
buf.set_raw(10, 5, Cell::from_char('!'));
buf.set_raw(40, 12, Cell::from_char('#'));
buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
buf
}
_ => unreachable!(),
};
let mut times_us = Vec::with_capacity(iterations as usize);
let mut last_changes = 0usize;
let mut last_runs = 0usize;
let mut last_checksum = 0u64;
for _ in 0..iterations {
let start = Instant::now();
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
let elapsed = start.elapsed();
last_changes = diff.len();
last_runs = runs.len();
last_checksum = fnv1a_hash(diff.changes());
times_us.push(elapsed.as_micros() as u64);
}
times_us.sort();
let len = times_us.len();
let p50 = times_us[len / 2];
let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
eprintln!(
"{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
seed,
width,
height,
scene_type,
last_changes,
last_runs,
p50,
p95,
p99,
last_checksum
);
let budget_us = match (width, height) {
(80, 24) => 500, (120, 40) => 1000, _ => 2000,
};
for _ in 0..3 {
let diff = BufferDiff::compute(&old, &new);
assert_eq!(
fnv1a_hash(diff.changes()),
last_checksum,
"diff must be deterministic"
);
}
if p95 > budget_us {
eprintln!(
"WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
);
}
}
}
#[test]
fn perf_dirty_diff_large_screen_regression() {
use std::time::Instant;
let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(50u32);
let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
.ok()
.and_then(|value| value.parse::<f64>().ok())
.unwrap_or(2.0);
let cases: &[(u16, u16, &str, f64)] = &[
(200, 60, "sparse_5pct", 5.0),
(240, 80, "sparse_5pct", 5.0),
(200, 60, "single_row", 0.0),
(240, 80, "single_row", 0.0),
];
for &(width, height, pattern, pct) in cases {
let old = Buffer::new(width, height);
let mut new = old.clone();
if pattern == "single_row" {
for x in 0..width {
new.set_raw(x, 0, Cell::from_char('X'));
}
} else {
let total = width as usize * height as usize;
let to_change = ((total as f64) * pct / 100.0) as usize;
for i in 0..to_change {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 11 + 5) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
new.set_raw(x, y, Cell::from_char(ch));
}
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
let change_count = full.len();
let dirty_rows = new.dirty_row_count();
assert_eq!(
full.changes(),
dirty.changes(),
"dirty diff must match full diff for {width}x{height} {pattern}"
);
let mut full_times = Vec::with_capacity(iterations as usize);
let mut dirty_times = Vec::with_capacity(iterations as usize);
for _ in 0..iterations {
let start = Instant::now();
let diff = BufferDiff::compute(&old, &new);
std::hint::black_box(diff.len());
full_times.push(start.elapsed().as_micros() as u64);
let start = Instant::now();
let diff = BufferDiff::compute_dirty(&old, &new);
std::hint::black_box(diff.len());
dirty_times.push(start.elapsed().as_micros() as u64);
}
full_times.sort();
dirty_times.sort();
let len = full_times.len();
let p50_idx = len / 2;
let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
let full_p50 = full_times[p50_idx];
let full_p95 = full_times[p95_idx];
let dirty_p50 = dirty_times[p50_idx];
let dirty_p95 = dirty_times[p95_idx];
let denom = full_p50.max(1) as f64;
let ratio = dirty_p50 as f64 / denom;
eprintln!(
"{{\"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\":{}}}",
width,
height,
pattern,
iterations,
change_count,
dirty_rows,
full_p50,
full_p95,
dirty_p50,
dirty_p95,
ratio,
max_slowdown
);
assert!(
ratio <= max_slowdown,
"dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
);
}
}
#[test]
fn tile_diff_matches_compute_for_sparse_tiles() {
let width = 200;
let height = 60;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
for x in 0..10u16 {
new.set_raw(x, 0, Cell::from_char('X'));
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
let stats = dirty
.last_tile_stats()
.expect("tile stats should be recorded");
assert!(
stats.fallback.is_none(),
"tile path should be used for sparse tiles"
);
}
fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
let mut buffer = Buffer::new(width, height);
buffer.clear_dirty();
for &(x, y, ch) in changes {
buffer.set_raw(x, y, Cell::from_char(ch));
}
buffer
}
fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
let mut diff = BufferDiff::new();
*diff.tile_config_mut() = config;
diff.compute_dirty_into(old, new);
diff.last_tile_stats()
.expect("tile stats should be recorded")
}
#[test]
fn tile_fallback_disabled_when_config_off() {
let width = 64;
let height = 32;
let old = Buffer::new(width, height);
let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
let config = TileDiffConfig {
enabled: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX,
..Default::default()
};
let stats = tile_stats_for_config(&old, &new, config);
assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
}
#[test]
fn tile_fallback_small_screen_when_below_threshold() {
let width = 64;
let height = 32;
let old = Buffer::new(width, height);
let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
let config = TileDiffConfig {
enabled: true,
min_cells_for_tiles: width as usize * height as usize + 1,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX,
..Default::default()
};
let stats = tile_stats_for_config(&old, &new, config);
assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
}
#[test]
fn tile_fallback_too_many_tiles_when_budget_exceeded() {
let width = 64;
let height = 64;
let old = Buffer::new(width, height);
let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: true,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: 4,
};
let stats = tile_stats_for_config(&old, &new, config);
assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
}
#[test]
fn tile_fallback_dense_tiles_when_ratio_exceeded() {
let width = 64;
let height = 32;
let old = Buffer::new(width, height);
let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: true,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 0.05,
max_tiles: usize::MAX / 4,
};
let stats = tile_stats_for_config(&old, &new, config);
assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
}
#[test]
fn tile_builder_skips_clean_rows_when_enabled() {
let width = 200;
let height = 60;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(3, 0, Cell::from_char('X'));
new.set_raw(4, 10, Cell::from_char('Y'));
let base = TileDiffConfig {
enabled: true,
tile_w: 16,
tile_h: 8,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX,
..Default::default()
};
let config_full = TileDiffConfig {
skip_clean_rows: false,
..base.clone()
};
let config_skip = TileDiffConfig {
skip_clean_rows: true,
..base
};
let stats_full = tile_stats_for_config(&old, &new, config_full);
let stats_skip = tile_stats_for_config(&old, &new, config_skip);
assert!(
stats_full.sat_build_cells > 0,
"full scan should process cells, got 0"
);
assert!(
stats_skip.sat_build_cells < stats_full.sat_build_cells,
"skip_clean_rows should process fewer cells than full scan: skip={} full={}",
stats_skip.sat_build_cells,
stats_full.sat_build_cells
);
assert!(
stats_skip.sat_build_cells <= stats_full.sat_build_cells / 2,
"skip_clean_rows should save at least 50%: skip={} full={}",
stats_skip.sat_build_cells,
stats_full.sat_build_cells
);
}
fn lcg_next(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state
}
fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
let width = buf.width().max(1) as u64;
let height = buf.height().max(1) as u64;
let mut state = seed;
for i in 0..count {
let v = lcg_next(&mut state);
let x = (v % width) as u16;
let y = ((v >> 32) % height) as u16;
let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
buf.set_raw(x, y, Cell::from_char(ch));
}
}
fn tile_diag(stats: &TileDiffStats) -> String {
let tile_size = stats.tile_w as usize * stats.tile_h as usize;
format!(
"tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
stats.dirty_tiles,
stats.skipped_tiles,
stats.dirty_cells,
stats.dirty_tile_ratio,
stats.dirty_cell_ratio,
stats.scanned_tiles,
stats.fallback
)
}
fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
let mut diff = BufferDiff::new();
{
let config = diff.tile_config_mut();
config.enabled = true;
config.tile_w = 8;
config.tile_h = 8;
config.min_cells_for_tiles = 0;
config.dense_cell_ratio = 1.1;
config.dense_tile_ratio = 1.1;
config.max_tiles = usize::MAX / 4;
}
diff.compute_dirty_into(old, new);
let stats = diff
.last_tile_stats()
.expect("tile stats should be recorded");
(diff, stats)
}
fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
let full = BufferDiff::compute(old, new);
let (dirty, stats) = diff_with_forced_tiles(old, new);
let diag = tile_diag(&stats);
assert!(
stats.fallback.is_none(),
"tile diff fallback ({label}) {w}x{h}: {diag}",
w = old.width(),
h = old.height()
);
assert!(
full.changes() == dirty.changes(),
"tile diff mismatch ({label}) {w}x{h}: {diag}",
w = old.width(),
h = old.height()
);
}
#[test]
fn tile_diff_equivalence_small_and_odd_sizes() {
let cases: &[(u16, u16, usize)] = &[
(1, 1, 1),
(2, 1, 1),
(1, 2, 1),
(5, 3, 4),
(7, 13, 12),
(15, 9, 20),
(31, 5, 12),
];
for (idx, &(width, height, changes)) in cases.iter().enumerate() {
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
assert_tile_diff_equivalence(&old, &new, "small_odd");
}
}
#[test]
fn tile_diff_equivalence_large_sparse_random() {
let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
for (idx, &(width, height)) in cases.iter().enumerate() {
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
let total = width as usize * height as usize;
let changes = (total / 100).max(1);
apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
assert_tile_diff_equivalence(&old, &new, "large_sparse");
}
}
#[test]
fn tile_diff_equivalence_row_and_full_buffer() {
let width = 200u16;
let height = 60u16;
let old = Buffer::new(width, height);
let mut row = old.clone();
row.clear_dirty();
for x in 0..width {
row.set_raw(x, 0, Cell::from_char('R'));
}
assert_tile_diff_equivalence(&old, &row, "single_row");
let mut full = old.clone();
full.clear_dirty();
for y in 0..height {
for x in 0..width {
full.set_raw(x, y, Cell::from_char('F'));
}
}
assert_tile_diff_equivalence(&old, &full, "full_buffer");
}
#[test]
fn full_zero_width_returns_empty() {
let diff = BufferDiff::full(0, 10);
assert!(diff.is_empty());
}
#[test]
fn full_zero_height_returns_empty() {
let diff = BufferDiff::full(10, 0);
assert!(diff.is_empty());
}
#[test]
fn full_zero_both_returns_empty() {
let diff = BufferDiff::full(0, 0);
assert!(diff.is_empty());
}
#[test]
fn full_single_cell_has_one_change() {
let diff = BufferDiff::full(1, 1);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(0, 0)]);
}
#[test]
fn full_row_major_order() {
let diff = BufferDiff::full(2, 3);
assert_eq!(
diff.changes(),
&[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]
);
}
#[test]
fn runs_into_matches_runs_output() {
let old = Buffer::new(20, 5);
let mut new = Buffer::new(20, 5);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 0, Cell::from_char('B'));
new.set_raw(5, 2, Cell::from_char('C'));
new.set_raw(10, 4, Cell::from_char('D'));
new.set_raw(11, 4, Cell::from_char('E'));
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
let mut runs_buf = Vec::new();
diff.runs_into(&mut runs_buf);
assert_eq!(runs, runs_buf);
}
#[test]
fn runs_into_clears_previous_content() {
let diff = BufferDiff::new();
let mut out = vec![ChangeRun::new(0, 0, 0)];
diff.runs_into(&mut out);
assert!(out.is_empty());
}
#[test]
fn runs_into_preserves_capacity() {
let old = Buffer::new(10, 2);
let mut new = Buffer::new(10, 2);
new.set_raw(0, 0, Cell::from_char('A'));
let diff = BufferDiff::compute(&old, &new);
let mut out = Vec::with_capacity(64);
diff.runs_into(&mut out);
assert_eq!(out.len(), 1);
assert!(out.capacity() >= 64);
}
#[test]
fn change_run_fields_accessible() {
let run = ChangeRun::new(7, 3, 10);
assert_eq!(run.y, 7);
assert_eq!(run.x0, 3);
assert_eq!(run.x1, 10);
assert_eq!(run.len(), 8);
assert!(!run.is_empty());
}
#[test]
fn change_run_single_cell_not_empty() {
let run = ChangeRun::new(0, 5, 5);
assert_eq!(run.len(), 1);
assert!(!run.is_empty());
}
#[test]
fn change_run_debug_format() {
let run = ChangeRun::new(1, 2, 3);
let dbg = format!("{:?}", run);
assert!(dbg.contains("ChangeRun"), "Debug output: {dbg}");
}
#[test]
fn change_run_clone_copy_eq() {
let run = ChangeRun::new(5, 10, 20);
let copied = run; assert_eq!(run, copied);
let cloned: ChangeRun = run; assert_eq!(run, cloned);
}
#[test]
fn change_run_ne() {
let a = ChangeRun::new(0, 0, 5);
let b = ChangeRun::new(0, 0, 6);
assert_ne!(a, b);
}
#[test]
fn tile_diff_config_default_values() {
let config = TileDiffConfig::default();
assert!(config.enabled);
assert_eq!(config.tile_w, 16);
assert_eq!(config.tile_h, 8);
assert!(config.skip_clean_rows);
assert_eq!(config.min_cells_for_tiles, 12_000);
assert!((config.dense_cell_ratio - 0.25).abs() < f64::EPSILON);
assert!((config.dense_tile_ratio - 0.60).abs() < f64::EPSILON);
assert_eq!(config.max_tiles, 4096);
}
#[test]
fn tile_diff_config_builder_methods() {
let config = TileDiffConfig::default()
.with_enabled(false)
.with_tile_size(32, 32)
.with_min_cells_for_tiles(500)
.with_skip_clean_rows(false)
.with_dense_cell_ratio(0.5)
.with_dense_tile_ratio(0.8)
.with_max_tiles(100);
assert!(!config.enabled);
assert_eq!(config.tile_w, 32);
assert_eq!(config.tile_h, 32);
assert!(!config.skip_clean_rows);
assert_eq!(config.min_cells_for_tiles, 500);
assert!((config.dense_cell_ratio - 0.5).abs() < f64::EPSILON);
assert!((config.dense_tile_ratio - 0.8).abs() < f64::EPSILON);
assert_eq!(config.max_tiles, 100);
}
#[test]
fn tile_diff_config_debug_clone() {
let config = TileDiffConfig::default();
let dbg = format!("{:?}", config);
assert!(dbg.contains("TileDiffConfig"), "Debug: {dbg}");
let cloned = config.clone();
assert_eq!(cloned.tile_w, 16);
}
#[test]
fn tile_diff_fallback_as_str_all_variants() {
assert_eq!(TileDiffFallback::Disabled.as_str(), "disabled");
assert_eq!(TileDiffFallback::SmallScreen.as_str(), "small_screen");
assert_eq!(TileDiffFallback::DirtyAll.as_str(), "dirty_all");
assert_eq!(TileDiffFallback::DenseCells.as_str(), "dense_cells");
assert_eq!(TileDiffFallback::DenseTiles.as_str(), "dense_tiles");
assert_eq!(TileDiffFallback::TooManyTiles.as_str(), "too_many_tiles");
assert_eq!(TileDiffFallback::Overflow.as_str(), "overflow");
}
#[test]
fn tile_diff_fallback_traits() {
let a = TileDiffFallback::Disabled;
let b = a; assert_eq!(a, b);
let c: TileDiffFallback = a; assert_eq!(a, c);
assert_ne!(a, TileDiffFallback::Overflow);
let dbg = format!("{:?}", a);
assert!(dbg.contains("Disabled"), "Debug: {dbg}");
}
#[test]
fn tile_builder_dirty_all_fallback() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default().with_min_cells_for_tiles(0);
let dirty_rows = vec![true; 10];
let dirty_bits = vec![1u8; 200];
let input = TileDiffInput {
width: 20,
height: 10,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 200,
dirty_all: true,
};
let result = builder.build(&config, input);
assert!(matches!(
result,
TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DirtyAll)
));
}
#[test]
fn tile_builder_dense_cells_fallback() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default()
.with_min_cells_for_tiles(0)
.with_dense_cell_ratio(0.10);
let w = 20u16;
let h = 10u16;
let total = (w as usize) * (h as usize);
let dirty_count = total / 10;
let dirty_rows = vec![true; h as usize];
let dirty_bits = vec![0u8; total];
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: dirty_count,
dirty_all: false,
};
let result = builder.build(&config, input);
assert!(matches!(
result,
TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
));
}
#[test]
fn tile_builder_reuse_across_builds() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default()
.with_min_cells_for_tiles(0)
.with_dense_tile_ratio(1.1)
.with_dense_cell_ratio(1.1)
.with_max_tiles(usize::MAX / 4);
let dirty_rows_1 = vec![true; 10];
let mut dirty_bits_1 = vec![0u8; 200];
dirty_bits_1[0] = 1;
let input_1 = TileDiffInput {
width: 20,
height: 10,
dirty_rows: &dirty_rows_1,
dirty_bits: &dirty_bits_1,
dirty_cells: 1,
dirty_all: false,
};
let result_1 = builder.build(&config, input_1);
assert!(matches!(result_1, TileDiffBuild::UseTiles(_)));
let dirty_rows_2 = vec![true; 15];
let mut dirty_bits_2 = vec![0u8; 450];
dirty_bits_2[200] = 1;
let input_2 = TileDiffInput {
width: 30,
height: 15,
dirty_rows: &dirty_rows_2,
dirty_bits: &dirty_bits_2,
dirty_cells: 1,
dirty_all: false,
};
let result_2 = builder.build(&config, input_2);
assert!(matches!(result_2, TileDiffBuild::UseTiles(_)));
}
#[test]
fn tile_builder_default_matches_new() {
let from_new = TileDiffBuilder::new();
let from_default = TileDiffBuilder::default();
assert_eq!(format!("{:?}", from_new), format!("{:?}", from_default));
}
#[test]
fn tile_params_total_tiles_and_cells() {
let params = TileParams {
width: 100,
height: 50,
tile_w: 16,
tile_h: 8,
tiles_x: 7, tiles_y: 7, };
assert_eq!(params.total_tiles(), 49);
assert_eq!(params.total_cells(), 5000);
}
#[test]
fn tile_diff_stats_from_builder() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default()
.with_min_cells_for_tiles(0)
.with_dense_tile_ratio(1.1)
.with_dense_cell_ratio(1.1)
.with_max_tiles(usize::MAX / 4);
let w = 32u16;
let h = 16u16;
let dirty_rows = vec![true; h as usize];
let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
dirty_bits[0] = 1;
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 1,
dirty_all: false,
};
let result = builder.build(&config, input);
match result {
TileDiffBuild::UseTiles(plan) => {
let stats = plan.stats;
assert_eq!(stats.width, w);
assert_eq!(stats.height, h);
assert_eq!(stats.tile_w, 16);
assert_eq!(stats.tile_h, 8);
assert_eq!(stats.tiles_x, 2);
assert_eq!(stats.tiles_y, 2);
assert_eq!(stats.total_tiles, 4);
assert_eq!(stats.dirty_cells, 1);
assert_eq!(stats.dirty_tiles, 1);
assert_eq!(stats.skipped_tiles, 3);
assert!(stats.fallback.is_none());
let copy = stats;
assert_eq!(copy.width, stats.width);
}
_ => unreachable!("expected UseTiles"),
}
}
#[test]
fn tile_size_clamped_to_min_8() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig {
enabled: true,
tile_w: 1, tile_h: 0, skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; 16];
let mut dirty_bits = vec![0u8; 256]; dirty_bits[0] = 1;
let input = TileDiffInput {
width: 16,
height: 16,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 1,
dirty_all: false,
};
let result = builder.build(&config, input);
match result {
TileDiffBuild::UseTiles(plan) => {
assert_eq!(plan.stats.tile_w, 8);
assert_eq!(plan.stats.tile_h, 8);
}
_ => unreachable!("expected UseTiles"),
}
}
#[test]
fn tile_size_clamped_to_max_64() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig {
enabled: true,
tile_w: 255, tile_h: 100, skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; 128];
let mut dirty_bits = vec![0u8; 128 * 128];
dirty_bits[0] = 1;
let input = TileDiffInput {
width: 128,
height: 128,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 1,
dirty_all: false,
};
let result = builder.build(&config, input);
match result {
TileDiffBuild::UseTiles(plan) => {
assert_eq!(plan.stats.tile_w, 64);
assert_eq!(plan.stats.tile_h, 64);
}
_ => unreachable!("expected UseTiles"),
}
}
#[test]
fn buffer_diff_default_is_empty() {
let diff: BufferDiff = Default::default();
assert!(diff.is_empty());
assert_eq!(diff.len(), 0);
assert!(diff.last_tile_stats().is_none());
}
#[test]
fn buffer_diff_debug_format() {
let diff = BufferDiff::new();
let dbg = format!("{:?}", diff);
assert!(dbg.contains("BufferDiff"), "Debug: {dbg}");
}
#[test]
fn buffer_diff_clone_preserves_changes() {
let old = Buffer::new(5, 5);
let mut new = Buffer::new(5, 5);
new.set_raw(2, 3, Cell::from_char('X'));
let diff = BufferDiff::compute(&old, &new);
let cloned = diff.clone();
assert_eq!(diff.changes(), cloned.changes());
}
#[test]
fn compute_into_replaces_previous_changes() {
let mut diff = BufferDiff::new();
let old1 = Buffer::new(5, 5);
let mut new1 = Buffer::new(5, 5);
new1.set_raw(0, 0, Cell::from_char('A'));
diff.compute_into(&old1, &new1);
assert_eq!(diff.len(), 1);
let old2 = Buffer::new(3, 3);
let mut new2 = Buffer::new(3, 3);
new2.set_raw(1, 1, Cell::from_char('B'));
new2.set_raw(2, 2, Cell::from_char('C'));
diff.compute_into(&old2, &new2);
assert_eq!(diff.len(), 2);
assert_eq!(diff.changes(), &[(1, 1), (2, 2)]);
}
#[test]
fn compute_into_identical_clears() {
let mut diff = BufferDiff::new();
let old = Buffer::new(5, 5);
let mut new = Buffer::new(5, 5);
new.set_raw(0, 0, Cell::from_char('X'));
diff.compute_into(&old, &new);
assert_eq!(diff.len(), 1);
diff.compute_into(&old, &old);
assert!(diff.is_empty());
}
#[test]
fn compute_dirty_into_no_dirty_rows() {
let old = Buffer::new(10, 10);
let mut new = old.clone();
new.clear_dirty();
let mut diff = BufferDiff::new();
diff.compute_dirty_into(&old, &new);
assert!(diff.is_empty());
}
#[test]
fn compute_dirty_into_reuse() {
let mut diff = BufferDiff::new();
let old = Buffer::new(10, 10);
let mut new1 = old.clone();
new1.clear_dirty();
new1.set_raw(5, 5, Cell::from_char('X'));
diff.compute_dirty_into(&old, &new1);
assert_eq!(diff.len(), 1);
let mut new2 = old.clone();
new2.clear_dirty();
new2.set_raw(3, 3, Cell::from_char('Y'));
new2.set_raw(7, 7, Cell::from_char('Z'));
diff.compute_dirty_into(&old, &new2);
assert_eq!(diff.len(), 2);
assert_eq!(diff.changes(), &[(3, 3), (7, 7)]);
}
#[test]
fn last_tile_stats_none_after_compute_into() {
let mut diff = BufferDiff::new();
let old = Buffer::new(5, 5);
let new = Buffer::new(5, 5);
diff.compute_into(&old, &new);
assert!(diff.last_tile_stats().is_none());
}
#[test]
fn last_tile_stats_some_after_compute_dirty_into() {
let mut diff = BufferDiff::new();
let old = Buffer::new(10, 10);
let mut new = old.clone();
new.set_raw(0, 0, Cell::from_char('A'));
diff.compute_dirty_into(&old, &new);
let stats = diff.last_tile_stats().expect("should have tile stats");
assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
}
#[test]
fn tile_config_mut_modifies_behavior() {
let old = Buffer::new(200, 60);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(0, 0, Cell::from_char('X'));
let mut diff = BufferDiff::new();
diff.compute_dirty_into(&old, &new);
let stats = diff.last_tile_stats().expect("stats");
assert!(
stats.fallback.is_none(),
"tiles should be active for 200x60"
);
diff.tile_config_mut().enabled = false;
diff.compute_dirty_into(&old, &new);
let stats = diff.last_tile_stats().expect("stats");
assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
}
#[test]
fn row_scan_width_31_below_row_block_size() {
let old = Buffer::new(31, 1);
let mut new = Buffer::new(31, 1);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(15, 0, Cell::from_char('B'));
new.set_raw(30, 0, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 3);
assert_eq!(diff.changes(), &[(0, 0), (15, 0), (30, 0)]);
}
#[test]
fn row_scan_width_32_exact_row_block_size() {
let old = Buffer::new(32, 1);
let mut new = Buffer::new(32, 1);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(15, 0, Cell::from_char('B'));
new.set_raw(31, 0, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 3);
assert_eq!(diff.changes(), &[(0, 0), (15, 0), (31, 0)]);
}
#[test]
fn row_scan_width_33_one_past_row_block_size() {
let old = Buffer::new(33, 1);
let mut new = Buffer::new(33, 1);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(31, 0, Cell::from_char('B'));
new.set_raw(32, 0, Cell::from_char('C'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 3);
assert_eq!(diff.changes(), &[(0, 0), (31, 0), (32, 0)]);
}
#[test]
fn dense_cells_exact_threshold_triggers_fallback() {
let mut builder = TileDiffBuilder::new();
let w = 20u16;
let h = 20u16;
let total = (w as usize) * (h as usize); let dirty_count = total / 4;
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 0.25,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; h as usize];
let dirty_bits = vec![1u8; total];
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: dirty_count,
dirty_all: false,
};
let result = builder.build(&config, input);
assert!(matches!(
result,
TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseCells)
));
}
#[test]
fn dense_cells_just_below_threshold_passes() {
let mut builder = TileDiffBuilder::new();
let w = 20u16;
let h = 20u16;
let total = (w as usize) * (h as usize); let dirty_count = total / 4 - 1;
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 0.25,
dense_tile_ratio: 1.1,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; h as usize];
let mut dirty_bits = vec![0u8; total];
for bit in dirty_bits.iter_mut().take(dirty_count) {
*bit = 1;
}
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: dirty_count,
dirty_all: false,
};
let result = builder.build(&config, input);
match &result {
TileDiffBuild::Fallback(stats) => {
assert_ne!(
stats.fallback,
Some(TileDiffFallback::DenseCells),
"should not trigger DenseCells below 25%: {:?}",
stats.fallback
);
}
TileDiffBuild::UseTiles(_) => { }
}
}
#[test]
fn switch_between_compute_and_dirty() {
let mut diff = BufferDiff::new();
let old = Buffer::new(10, 10);
let mut new = Buffer::new(10, 10);
new.set_raw(3, 3, Cell::from_char('X'));
diff.compute_into(&old, &new);
assert_eq!(diff.len(), 1);
assert!(diff.last_tile_stats().is_none());
diff.compute_dirty_into(&old, &new);
assert_eq!(diff.len(), 1);
assert!(diff.last_tile_stats().is_some());
diff.compute_into(&old, &new);
assert_eq!(diff.len(), 1);
assert!(diff.last_tile_stats().is_none());
}
#[test]
#[should_panic(expected = "buffer heights must match")]
fn compute_panics_on_height_mismatch() {
let old = Buffer::new(5, 5);
let new = Buffer::new(5, 4);
let _ = BufferDiff::compute(&old, &new);
}
#[test]
#[should_panic(expected = "buffer widths must match")]
fn compute_dirty_panics_on_width_mismatch() {
let old = Buffer::new(5, 5);
let new = Buffer::new(4, 5);
let _ = BufferDiff::compute_dirty(&old, &new);
}
#[test]
#[should_panic(expected = "buffer heights must match")]
fn compute_dirty_panics_on_height_mismatch() {
let old = Buffer::new(5, 5);
let new = Buffer::new(5, 4);
let _ = BufferDiff::compute_dirty(&old, &new);
}
#[test]
fn tile_diff_input_debug_copy() {
let dirty_rows = vec![true; 2];
let dirty_bits = vec![0u8; 4];
let input = TileDiffInput {
width: 2,
height: 2,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 0,
dirty_all: false,
};
let dbg = format!("{:?}", input);
assert!(dbg.contains("TileDiffInput"), "Debug: {dbg}");
let copy = input; assert_eq!(copy.width, input.width);
}
#[test]
fn tile_diff_build_debug() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default();
let dirty_rows = vec![true; 1];
let dirty_bits = vec![0u8; 4];
let input = TileDiffInput {
width: 4,
height: 1,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 0,
dirty_all: false,
};
let result = builder.build(&config, input);
let dbg = format!("{:?}", result);
assert!(dbg.contains("Fallback"), "Debug: {dbg}");
}
#[test]
fn full_diff_runs_one_per_row() {
let diff = BufferDiff::full(10, 3);
let runs = diff.runs();
assert_eq!(runs.len(), 3);
for (i, run) in runs.iter().enumerate() {
assert_eq!(run.y, i as u16);
assert_eq!(run.x0, 0);
assert_eq!(run.x1, 9);
assert_eq!(run.len(), 10);
}
}
#[test]
fn dirty_diff_skips_false_positive_rows() {
let old = Buffer::new(10, 5);
let mut new = old.clone();
new.clear_dirty();
for y in 0..5u16 {
new.set_raw(0, y, Cell::default());
}
new.set_raw(5, 1, Cell::from_char('A'));
new.set_raw(7, 3, Cell::from_char('B'));
let diff = BufferDiff::compute_dirty(&old, &new);
assert_eq!(diff.len(), 2);
assert!(diff.changes().contains(&(5, 1)));
assert!(diff.changes().contains(&(7, 3)));
}
#[test]
fn bg_only_change_detected() {
let old = Buffer::new(5, 1);
let mut new = Buffer::new(5, 1);
new.set_raw(2, 0, Cell::default().with_bg(PackedRgba::rgb(0, 0, 255)));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 1);
assert_eq!(diff.changes(), &[(2, 0)]);
}
#[test]
fn changes_at_buffer_corners() {
let w = 100u16;
let h = 50u16;
let old = Buffer::new(w, h);
let mut new = Buffer::new(w, h);
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(w - 1, h - 1, Cell::from_char('Z'));
let diff = BufferDiff::compute(&old, &new);
assert_eq!(diff.len(), 2);
assert_eq!(diff.changes()[0], (0, 0));
assert_eq!(diff.changes()[1], (w - 1, h - 1));
let runs = diff.runs();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0], ChangeRun::new(0, 0, 0));
assert_eq!(runs[1], ChangeRun::new(h - 1, w - 1, w - 1));
}
#[test]
fn tile_params_debug_copy() {
let params = TileParams {
width: 80,
height: 24,
tile_w: 16,
tile_h: 8,
tiles_x: 5,
tiles_y: 3,
};
let dbg = format!("{:?}", params);
assert!(dbg.contains("TileParams"), "Debug: {dbg}");
let copy = params; assert_eq!(copy.width, params.width);
let cloned: TileParams = params; assert_eq!(cloned.tiles_x, params.tiles_x);
}
#[test]
fn tile_diff_stats_debug_copy() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default();
let dirty_rows = vec![true; 1];
let dirty_bits = vec![0u8; 1];
let input = TileDiffInput {
width: 1,
height: 1,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 0,
dirty_all: false,
};
let result = builder.build(&config, input);
match result {
TileDiffBuild::Fallback(stats) => {
let dbg = format!("{:?}", stats);
assert!(dbg.contains("TileDiffStats"), "Debug: {dbg}");
let copy = stats;
assert_eq!(copy.width, stats.width);
let cloned: TileDiffStats = stats; assert_eq!(cloned.height, stats.height);
}
_ => unreachable!("expected Fallback for 1x1"),
}
}
#[test]
fn compute_dirty_with_targeted_spans() {
let width = 100u16;
let height = 10u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(10, 3, Cell::from_char('A'));
new.set_raw(11, 3, Cell::from_char('B'));
new.set_raw(50, 7, Cell::from_char('C'));
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
assert_eq!(dirty.len(), 3);
assert!(dirty.changes().contains(&(10, 3)));
assert!(dirty.changes().contains(&(11, 3)));
assert!(dirty.changes().contains(&(50, 7)));
}
#[test]
fn compute_dirty_spans_on_multiple_rows() {
let width = 80u16;
let height = 5u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(1, 0, Cell::from_char('B'));
new.set_raw(40, 2, Cell::from_char('C'));
new.set_raw(79, 4, Cell::from_char('D'));
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
assert_eq!(dirty.len(), 4);
}
#[test]
fn compute_dirty_span_with_false_positive_row() {
let old = Buffer::new(20, 3);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(5, 1, Cell::default());
new.set_raw(10, 2, Cell::from_char('X'));
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(dirty.len(), 1);
assert_eq!(dirty.changes(), &[(10, 2)]);
}
#[test]
fn compute_dirty_many_spans_per_row() {
let width = 200u16;
let height = 1u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
let positions: Vec<u16> = vec![5, 20, 35, 50, 65, 80, 95, 110, 125, 140];
for &x in &positions {
new.set_raw(x, 0, Cell::from_char('X'));
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
assert_eq!(dirty.len(), positions.len());
}
#[test]
fn tile_diff_with_dirty_spans_matches_full() {
let width = 200u16;
let height = 60u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(5, 2, Cell::from_char('A'));
new.set_raw(100, 30, Cell::from_char('B'));
new.set_raw(195, 55, Cell::from_char('C'));
let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
let full = BufferDiff::compute(&old, &new);
assert!(stats.fallback.is_none(), "tile path should be used");
assert_eq!(
stats.sat_build_cells, 3,
"span-aware tile build should only scan covered span cells"
);
assert_eq!(full.changes(), dirty_diff.changes());
assert_eq!(dirty_diff.len(), 3);
}
#[test]
fn tile_diff_with_spans_straddling_tile_boundary() {
let width = 200u16;
let height = 60u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(6, 0, Cell::from_char('A'));
new.set_raw(7, 0, Cell::from_char('B'));
new.set_raw(8, 0, Cell::from_char('C'));
new.set_raw(9, 0, Cell::from_char('D'));
let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
let full = BufferDiff::compute(&old, &new);
assert!(stats.fallback.is_none());
assert_eq!(full.changes(), dirty_diff.changes());
assert_eq!(dirty_diff.len(), 4);
}
#[test]
fn tile_diff_single_dirty_tile_skips_others() {
let width = 200u16;
let height = 60u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(3, 3, Cell::from_char('X'));
let (dirty_diff, stats) = diff_with_forced_tiles(&old, &new);
assert!(stats.fallback.is_none());
assert_eq!(dirty_diff.len(), 1);
assert!(stats.skipped_tiles > 0, "should skip clean tiles");
assert_eq!(stats.dirty_tiles, 1, "only one tile should be dirty");
}
#[test]
fn accumulate_tile_counts_range_single_cell_range() {
let mut tile_counts = vec![0u32; 4];
let dirty_bits = vec![0, 0, 1, 0, 0, 0, 0, 0];
let mut scanned_cells = 0usize;
accumulate_tile_counts_range(
&mut tile_counts,
&dirty_bits,
0,
4,
2,
2..3,
&mut scanned_cells,
)
.unwrap();
assert_eq!(scanned_cells, 1);
assert_eq!(tile_counts, vec![0, 1, 0, 0]);
}
#[test]
fn accumulate_tile_counts_range_single_tile_multi_cell_range() {
let mut tile_counts = vec![0u32; 4];
let dirty_bits = vec![0, 1, 0, 1, 1, 0, 0, 0];
let mut scanned_cells = 0usize;
accumulate_tile_counts_range(
&mut tile_counts,
&dirty_bits,
0,
4,
2,
4..6,
&mut scanned_cells,
)
.unwrap();
assert_eq!(scanned_cells, 2);
assert_eq!(tile_counts, vec![0, 0, 1, 0]);
}
#[test]
fn tile_diff_plan_fields_accessible() {
let mut builder = TileDiffBuilder::new();
let config = TileDiffConfig::default()
.with_min_cells_for_tiles(0)
.with_dense_tile_ratio(1.1)
.with_dense_cell_ratio(1.1)
.with_max_tiles(usize::MAX / 4);
let w = 32u16;
let h = 16u16;
let dirty_rows = vec![true; h as usize];
let mut dirty_bits = vec![0u8; (w as usize) * (h as usize)];
dirty_bits[0] = 1; dirty_bits[8 + 8 * w as usize] = 1;
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 2,
dirty_all: false,
};
let result = builder.build(&config, input);
match result {
TileDiffBuild::UseTiles(plan) => {
assert_eq!(plan.params.width, w);
assert_eq!(plan.params.height, h);
assert!(plan.params.tile_w >= 8);
assert!(plan.params.tile_h >= 8);
assert!(plan.params.tiles_x > 0);
assert!(plan.params.tiles_y > 0);
assert_eq!(plan.dirty_tiles.len(), plan.params.total_tiles());
let dirty_count: usize = plan.dirty_tiles.iter().filter(|&&d| d).count();
assert_eq!(dirty_count, plan.stats.dirty_tiles);
assert_eq!(plan.tile_counts.len(), plan.params.total_tiles());
let expected_sat_len = (plan.params.tiles_x + 1) * (plan.params.tiles_y + 1);
assert_eq!(plan.sat.len(), expected_sat_len);
let sat_w = plan.params.tiles_x + 1;
for tx in 0..sat_w {
assert_eq!(plan.sat[tx], 0, "SAT top border should be 0");
}
for ty in 0..plan.params.tiles_y + 1 {
assert_eq!(plan.sat[ty * sat_w], 0, "SAT left border should be 0");
}
}
_ => unreachable!("expected UseTiles"),
}
}
#[test]
fn dense_tiles_exact_threshold_triggers_fallback() {
let mut builder = TileDiffBuilder::new();
let w = 32u16;
let h = 32u16;
let total_cells = (w as usize) * (h as usize);
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 0.5,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; h as usize];
let mut dirty_bits = vec![0u8; total_cells];
for tile_y in 0..2 {
for tile_x in 0..4 {
let x = tile_x * 8;
let y = tile_y * 8;
dirty_bits[y * w as usize + x] = 1;
}
}
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 8,
dirty_all: false,
};
let result = builder.build(&config, input);
assert!(
matches!(
result,
TileDiffBuild::Fallback(stats) if stats.fallback == Some(TileDiffFallback::DenseTiles)
),
"8 of 16 tiles dirty (50%) should trigger DenseTiles with threshold 0.5"
);
}
#[test]
fn dense_tiles_just_below_threshold_passes() {
let mut builder = TileDiffBuilder::new();
let w = 32u16;
let h = 32u16;
let total_cells = (w as usize) * (h as usize);
let config = TileDiffConfig {
enabled: true,
tile_w: 8,
tile_h: 8,
skip_clean_rows: false,
min_cells_for_tiles: 0,
dense_cell_ratio: 1.1,
dense_tile_ratio: 0.5,
max_tiles: usize::MAX / 4,
};
let dirty_rows = vec![true; h as usize];
let mut dirty_bits = vec![0u8; total_cells];
for tile_idx in 0..7 {
let tile_x = tile_idx % 4;
let tile_y = tile_idx / 4;
let x = tile_x * 8;
let y = tile_y * 8;
dirty_bits[y * w as usize + x] = 1;
}
let input = TileDiffInput {
width: w,
height: h,
dirty_rows: &dirty_rows,
dirty_bits: &dirty_bits,
dirty_cells: 7,
dirty_all: false,
};
let result = builder.build(&config, input);
assert!(
matches!(result, TileDiffBuild::UseTiles(_)),
"7 of 16 tiles dirty (43.75%) should use tiles with threshold 0.5"
);
}
#[test]
fn span_diagnostics_with_no_dirty_spans() {
let mut buf = Buffer::new(20, 3);
buf.clear_dirty();
let diag = span_diagnostics(&buf);
assert!(
diag.contains("stats="),
"diagnostics should include stats: {diag}"
);
}
#[test]
fn span_diagnostics_with_dirty_cells() {
let mut buf = Buffer::new(20, 3);
buf.clear_dirty();
buf.set_raw(5, 1, Cell::from_char('X'));
let diag = span_diagnostics(&buf);
assert!(
diag.contains("stats="),
"diagnostics should include stats: {diag}"
);
}
#[test]
fn span_diagnostics_with_full_row() {
let mut buf = Buffer::new(20, 2);
buf.clear_dirty();
for x in 0..20u16 {
buf.set_raw(x, 0, Cell::from_char('X'));
}
let diag = span_diagnostics(&buf);
assert!(
diag.contains("stats="),
"diagnostics should include stats: {diag}"
);
}
#[test]
fn compute_dirty_matches_full_for_single_row_buffer() {
let old = Buffer::new(50, 1);
let mut new = old.clone();
new.set_raw(25, 0, Cell::from_char('M'));
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
}
#[test]
fn compute_dirty_matches_full_for_single_column_buffer() {
let old = Buffer::new(1, 50);
let mut new = old.clone();
new.set_raw(0, 25, Cell::from_char('M'));
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
}
#[test]
fn tile_config_chain_returns_same_type() {
let config = TileDiffConfig::default()
.with_enabled(true)
.with_tile_size(16, 16)
.with_min_cells_for_tiles(1000)
.with_skip_clean_rows(true)
.with_dense_cell_ratio(0.3)
.with_dense_tile_ratio(0.7)
.with_max_tiles(2048);
assert!(config.enabled);
assert_eq!(config.tile_w, 16);
assert_eq!(config.tile_h, 16);
assert_eq!(config.min_cells_for_tiles, 1000);
assert!(config.skip_clean_rows);
assert!((config.dense_cell_ratio - 0.3).abs() < f64::EPSILON);
assert!((config.dense_tile_ratio - 0.7).abs() < f64::EPSILON);
assert_eq!(config.max_tiles, 2048);
}
#[test]
fn runs_into_reuse_across_multiple_diffs() {
let mut runs_buf: Vec<ChangeRun> = Vec::new();
let old1 = Buffer::new(10, 2);
let mut new1 = Buffer::new(10, 2);
new1.set_raw(0, 0, Cell::from_char('A'));
new1.set_raw(1, 0, Cell::from_char('B'));
let diff1 = BufferDiff::compute(&old1, &new1);
diff1.runs_into(&mut runs_buf);
assert_eq!(runs_buf.len(), 1);
assert_eq!(runs_buf[0], ChangeRun::new(0, 0, 1));
let old2 = Buffer::new(5, 3);
let mut new2 = Buffer::new(5, 3);
new2.set_raw(2, 1, Cell::from_char('X'));
new2.set_raw(4, 2, Cell::from_char('Y'));
let diff2 = BufferDiff::compute(&old2, &new2);
diff2.runs_into(&mut runs_buf);
assert_eq!(runs_buf.len(), 2);
assert_eq!(runs_buf[0], ChangeRun::new(1, 2, 2));
assert_eq!(runs_buf[1], ChangeRun::new(2, 4, 4));
}
#[test]
fn full_diff_zero_dimensions_runs_empty() {
let diff_w0 = BufferDiff::full(0, 5);
assert!(diff_w0.runs().is_empty());
let diff_h0 = BufferDiff::full(5, 0);
assert!(diff_h0.runs().is_empty());
let diff_both = BufferDiff::full(0, 0);
assert!(diff_both.runs().is_empty());
}
#[test]
fn change_run_max_u16_values() {
let run = ChangeRun::new(u16::MAX, 1, u16::MAX);
assert_eq!(run.y, u16::MAX);
assert_eq!(run.x0, 1);
assert_eq!(run.x1, u16::MAX);
assert_eq!(run.len(), usize::from(u16::MAX));
let run2 = ChangeRun::new(u16::MAX, u16::MAX, u16::MAX);
assert_eq!(run2.len(), 1);
}
#[test]
fn tile_diff_equivalence_adjacent_tiles() {
let width = 200u16;
let height = 60u16;
let old = Buffer::new(width, height);
let mut new = old.clone();
new.clear_dirty();
new.set_raw(7, 0, Cell::from_char('A')); new.set_raw(8, 0, Cell::from_char('B')); new.set_raw(15, 0, Cell::from_char('C')); new.set_raw(16, 0, Cell::from_char('D'));
assert_tile_diff_equivalence(&old, &new, "adjacent_tile_boundaries");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use crate::cell::Cell;
use ftui_core::geometry::Rect;
use proptest::prelude::*;
#[test]
fn diff_apply_produces_target() {
proptest::proptest!(|(
width in 5u16..50,
height in 5u16..30,
num_changes in 0usize..200,
)| {
let old = Buffer::new(width, height);
let mut new = old.clone();
for i in 0..num_changes {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 11 + 5) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
new.set_raw(x, y, Cell::from_char(ch));
}
let diff = BufferDiff::compute(&old, &new);
let mut result = old.clone();
for (x, y) in diff.iter() {
let cell = *new.get_unchecked(x, y);
result.set_raw(x, y, cell);
}
for y in 0..height {
for x in 0..width {
let result_cell = result.get_unchecked(x, y);
let new_cell = new.get_unchecked(x, y);
prop_assert!(
result_cell.bits_eq(new_cell),
"Mismatch at ({}, {})",
x,
y
);
}
}
});
}
#[test]
fn identical_buffers_empty_diff() {
proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
let buf = Buffer::new(width, height);
let diff = BufferDiff::compute(&buf, &buf);
prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
});
}
#[test]
fn diff_contains_only_real_changes() {
proptest::proptest!(|(
width in 5u16..50,
height in 5u16..30,
num_changes in 0usize..100,
)| {
let old = Buffer::new(width, height);
let mut new = old.clone();
for i in 0..num_changes {
let x = (i * 7 + 3) as u16 % width;
let y = (i * 11 + 5) as u16 % height;
new.set_raw(x, y, Cell::from_char('X'));
}
let diff = BufferDiff::compute(&old, &new);
for (x, y) in diff.iter() {
let old_cell = old.get_unchecked(x, y);
let new_cell = new.get_unchecked(x, y);
prop_assert!(
!old_cell.bits_eq(new_cell),
"Diff includes unchanged cell at ({}, {})",
x,
y
);
}
});
}
#[test]
fn runs_are_contiguous() {
proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
let old = Buffer::new(width, height);
let mut new = old.clone();
for y in 0..height.min(5) {
for x in 0..width.min(10) {
new.set_raw(x, y, Cell::from_char('#'));
}
}
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
for run in runs {
prop_assert!(run.x1 >= run.x0, "Run has invalid range");
prop_assert!(!run.is_empty(), "Run should not be empty");
for x in run.x0..=run.x1 {
let old_cell = old.get_unchecked(x, run.y);
let new_cell = new.get_unchecked(x, run.y);
prop_assert!(
!old_cell.bits_eq(new_cell),
"Run includes unchanged cell at ({}, {})",
x,
run.y
);
}
}
});
}
#[test]
fn runs_cover_all_changes() {
proptest::proptest!(|(
width in 10u16..60,
height in 5u16..30,
num_changes in 1usize..100,
)| {
let old = Buffer::new(width, height);
let mut new = old.clone();
for i in 0..num_changes {
let x = (i * 13 + 7) as u16 % width;
let y = (i * 17 + 3) as u16 % height;
new.set_raw(x, y, Cell::from_char('X'));
}
let diff = BufferDiff::compute(&old, &new);
let runs = diff.runs();
let mut run_cells: std::collections::HashSet<(u16, u16)> =
std::collections::HashSet::new();
for run in &runs {
for x in run.x0..=run.x1 {
let was_new = run_cells.insert((x, run.y));
prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
}
}
for (x, y) in diff.iter() {
prop_assert!(
run_cells.contains(&(x, y)),
"Change at ({}, {}) not covered by runs",
x,
y
);
}
prop_assert_eq!(
run_cells.len(),
diff.len(),
"Run cell count should match diff change count"
);
});
}
#[test]
fn block_scan_matches_scalar() {
proptest::proptest!(|(
width in 1u16..200,
height in 1u16..20,
num_changes in 0usize..200,
)| {
use crate::cell::PackedRgba;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for i in 0..num_changes {
let x = (i * 13 + 7) as u16 % width;
let y = (i * 17 + 3) as u16 % height;
let fg = PackedRgba::rgb(
((i * 31) % 256) as u8,
((i * 47) % 256) as u8,
((i * 71) % 256) as u8,
);
new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
}
let diff = BufferDiff::compute(&old, &new);
let mut scalar_changes = Vec::new();
for y in 0..height {
for x in 0..width {
let old_cell = old.get_unchecked(x, y);
let new_cell = new.get_unchecked(x, y);
if !old_cell.bits_eq(new_cell) {
scalar_changes.push((x, y));
}
}
}
prop_assert_eq!(
diff.changes(),
&scalar_changes[..],
"Block scan should match scalar scan"
);
});
}
#[test]
fn property_diff_equivalence() {
proptest::proptest!(|(
width in 1u16..120,
height in 1u16..40,
num_changes in 0usize..300,
)| {
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
for i in 0..num_changes {
let x = (i * 13 + 7) as u16 % width;
let y = (i * 17 + 3) as u16 % height;
let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
let fg = crate::cell::PackedRgba::rgb(
((i * 31) % 256) as u8,
((i * 47) % 256) as u8,
((i * 71) % 256) as u8,
);
new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
prop_assert_eq!(
full.changes(),
dirty.changes(),
"dirty diff must match full diff (width={}, height={}, changes={})",
width,
height,
num_changes
);
let full_runs = full.runs();
let dirty_runs = dirty.runs();
prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
prop_assert_eq!(fr, dr, "run mismatch");
}
});
}
#[test]
fn property_diff_equivalence_complex_spans() {
proptest::proptest!(|(
width in 10u16..100,
height in 10u16..50,
ops in proptest::collection::vec(
prop_oneof![
// Single cell set
(Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
(Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
],
1..50
)
)| {
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
new.clear_dirty();
for (op_type, x, y, ch) in ops {
let x = x % width;
let y = y % height;
let cell = Cell::from_char(ch);
match op_type {
0 => new.set(x, y, cell),
1 => {
let w = ((x + 10).min(width) - x).max(1);
let h = ((y + 5).min(height) - y).max(1);
new.fill(Rect::new(x, y, w, h), cell);
}
_ => unreachable!(),
}
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
prop_assert_eq!(
full.changes(),
dirty.changes(),
"dirty diff (spans) must match full diff; {}",
super::span_diagnostics(&new)
);
});
}
#[test]
fn diff_is_idempotent() {
proptest::proptest!(|(
width in 5u16..60,
height in 5u16..30,
num_changes in 0usize..100,
)| {
let mut buf_a = Buffer::new(width, height);
let mut buf_b = Buffer::new(width, height);
for i in 0..num_changes {
let x = (i * 13 + 7) as u16 % width;
let y = (i * 17 + 3) as u16 % height;
buf_b.set_raw(x, y, Cell::from_char('X'));
}
let diff = BufferDiff::compute(&buf_a, &buf_b);
for (x, y) in diff.iter() {
let cell = *buf_b.get_unchecked(x, y);
buf_a.set_raw(x, y, cell);
}
let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
prop_assert!(
diff_after_first.is_empty(),
"After applying diff once, buffers should be identical (diff was {} changes)",
diff_after_first.len()
);
let before_second = buf_a.clone();
for (x, y) in diff.iter() {
let cell = *buf_b.get_unchecked(x, y);
buf_a.set_raw(x, y, cell);
}
let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
prop_assert!(
diff_after_second.is_empty(),
"Second diff application should be a no-op"
);
});
}
#[test]
fn no_ghosting_after_clear() {
proptest::proptest!(|(
width in 10u16..80,
height in 5u16..30,
num_content_cells in 1usize..200,
)| {
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
let mut expected_changes = std::collections::HashSet::new();
for i in 0..num_content_cells {
let x = (i * 13 + 7) as u16 % width;
let y = (i * 17 + 3) as u16 % height;
new.set_raw(x, y, Cell::from_char('#'));
expected_changes.insert((x, y));
}
let diff = BufferDiff::compute(&old, &new);
for (x, y) in expected_changes {
let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
prop_assert!(
in_diff,
"Content cell at ({}, {}) missing from diff - would ghost",
x,
y
);
}
for (x, y) in diff.iter() {
let old_cell = old.get_unchecked(x, y);
let new_cell = new.get_unchecked(x, y);
prop_assert!(
!old_cell.bits_eq(new_cell),
"Diff includes unchanged cell at ({}, {})",
x,
y
);
}
});
}
#[test]
fn diff_changes_are_monotonic() {
proptest::proptest!(|(
width in 10u16..80,
height in 5u16..30,
num_changes in 1usize..200,
)| {
let old = Buffer::new(width, height);
let mut new = old.clone();
for i in 0..num_changes {
let x = (i * 37 + 11) as u16 % width;
let y = (i * 53 + 7) as u16 % height;
new.set_raw(x, y, Cell::from_char('M'));
}
let diff = BufferDiff::compute(&old, &new);
let changes: Vec<_> = diff.iter().collect();
for window in changes.windows(2) {
let (x1, y1) = window[0];
let (x2, y2) = window[1];
let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
prop_assert!(
is_monotonic,
"Changes not monotonic: ({}, {}) should come before ({}, {})",
x1,
y1,
x2,
y2
);
}
});
}
}
#[cfg(test)]
mod span_edge_cases {
use super::*;
use crate::cell::Cell;
use proptest::prelude::*;
#[test]
fn test_span_diff_u16_max_width() {
let width = 65000;
let height = 1;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
new.clear_dirty();
new.set_raw(0, 0, Cell::from_char('A'));
new.set_raw(32500, 0, Cell::from_char('B'));
new.set_raw(64999, 0, Cell::from_char('C'));
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
assert_eq!(full.len(), 3);
let changes = full.changes();
assert!(changes.contains(&(0, 0)));
assert!(changes.contains(&(32500, 0)));
assert!(changes.contains(&(64999, 0)));
}
#[test]
fn test_span_full_row_dirty_overflow() {
let width = 1000;
let height = 1;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
new.clear_dirty();
for i in 0..70 {
let x = (i * 10) as u16;
new.set_raw(x, 0, Cell::from_char('X'));
}
let stats = new.dirty_span_stats();
assert!(
stats.rows_full_dirty > 0,
"Should have overflowed to full row"
);
assert_eq!(
stats.rows_with_spans, 0,
"Should have cleared spans on overflow"
);
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
assert_eq!(full.changes(), dirty.changes());
assert_eq!(full.len(), 70);
}
#[test]
fn test_span_diff_empty_rows() {
let width = 100;
let height = 10;
let old = Buffer::new(width, height);
let mut new = Buffer::new(width, height);
new.clear_dirty();
let dirty = BufferDiff::compute_dirty(&old, &new);
assert!(dirty.is_empty());
}
proptest! {
#[test]
fn property_span_diff_equivalence_large(
width in 1000u16..5000,
height in 10u16..50,
changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
) {
let w = width.min(2000);
let h = height.min(50);
let old = Buffer::new(w, h);
let mut new = Buffer::new(w, h);
new.clear_dirty();
for (raw_x, raw_y) in changes {
let x = raw_x % w;
let y = raw_y % h;
new.set_raw(x, y, Cell::from_char('Z'));
}
let full = BufferDiff::compute(&old, &new);
let dirty = BufferDiff::compute_dirty(&old, &new);
prop_assert_eq!(
full.changes(),
dirty.changes(),
"Large buffer mismatch: w={}, h={}, {}",
w,
h,
super::span_diagnostics(&new)
);
}
}
#[test]
fn certified_skip_produces_empty_diff() {
let old = Buffer::new(10, 5);
let new = Buffer::new(10, 5);
let mut diff = BufferDiff::new();
diff.compute_certified_into(&old, &new, DiffSkipHint::SkipDiff);
assert!(diff.is_empty(), "SkipDiff should produce zero changes");
}
#[test]
fn certified_full_diff_matches_standard() {
let old = Buffer::new(10, 5);
let mut new = Buffer::new(10, 5);
new.set(3, 2, Cell::from_char('X'));
let mut standard = BufferDiff::new();
standard.compute_dirty_into(&old, &new);
let mut certified = BufferDiff::new();
certified.compute_certified_into(&old, &new, DiffSkipHint::FullDiff);
assert_eq!(
standard.changes(),
certified.changes(),
"FullDiff hint should produce identical results to standard dirty diff"
);
}
#[test]
fn certified_narrow_to_rows_only_diffs_specified_rows() {
let old = Buffer::new(10, 5);
let mut new = Buffer::new(10, 5);
new.set(2, 1, Cell::from_char('A'));
new.set(5, 3, Cell::from_char('B'));
new.set(7, 4, Cell::from_char('C'));
let mut diff = BufferDiff::new();
diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![1, 3]));
let changes = diff.changes();
assert!(
changes.iter().any(|&(x, y)| x == 2 && y == 1),
"should find change at (2, 1)"
);
assert!(
changes.iter().any(|&(x, y)| x == 5 && y == 3),
"should find change at (5, 3)"
);
assert!(
!changes.iter().any(|&(_, y)| y == 4),
"should NOT find changes in row 4 (not in hint)"
);
}
#[test]
fn certified_narrow_skips_clean_rows() {
let old = Buffer::new(10, 5);
let new = Buffer::new(10, 5);
let mut diff = BufferDiff::new();
diff.compute_certified_into(&old, &new, DiffSkipHint::NarrowToRows(vec![0, 2, 4]));
assert!(
diff.is_empty(),
"narrowing to clean rows should produce zero changes"
);
}
#[test]
fn certified_narrow_out_of_bounds_rows_ignored() {
let old = Buffer::new(10, 5);
let mut new = Buffer::new(10, 5);
new.set(0, 0, Cell::from_char('X'));
let mut diff = BufferDiff::new();
diff.compute_certified_into(
&old,
&new,
DiffSkipHint::NarrowToRows(vec![0, 100, 200]), );
assert_eq!(diff.len(), 1, "should find the one change in row 0");
assert_eq!(diff.changes()[0], (0, 0));
}
#[test]
fn diff_skip_hint_labels() {
assert_eq!(DiffSkipHint::FullDiff.label(), "full-diff");
assert_eq!(DiffSkipHint::SkipDiff.label(), "skip-diff");
assert_eq!(DiffSkipHint::NarrowToRows(vec![]).label(), "narrow-to-rows");
}
#[test]
fn diff_skip_hint_skips_work() {
assert!(!DiffSkipHint::FullDiff.skips_work());
assert!(DiffSkipHint::SkipDiff.skips_work());
assert!(DiffSkipHint::NarrowToRows(vec![1]).skips_work());
}
}