Skip to main content

frankenterm_core/
patch.rs

1//! Incremental patch API: dirty tracking, grid diff, and change runs.
2//!
3//! The patch module provides an efficient change representation so renderers
4//! (native ANSI presenter, WebGPU, trace replayer) can update incrementally.
5//!
6//! # Architecture
7//!
8//! ```text
9//! Grid mutations → DirtyTracker (row + span hints)
10//!                       ↓
11//! GridDiff::diff_dirty(old, new, tracker) → Patch
12//!                       ↓
13//! Patch::runs() → Vec<ChangeRun> (for cursor-based output)
14//! Patch::updates  → Vec<CellUpdate> (for instance-based GPU upload)
15//! ```
16//!
17//! # Ordering guarantee
18//!
19//! All outputs are in **stable row-major order**: row ascending, then column
20//! ascending within each row. This is deterministic for identical inputs.
21
22use crate::cell::Cell;
23use crate::grid::Grid;
24
25// ── Dirty span ───────────────────────────────────────────────────────
26
27/// A half-open column range `[start, end)` marking dirty cells in a row.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct DirtySpan {
30    /// Start column (inclusive).
31    pub start: u16,
32    /// End column (exclusive).
33    pub end: u16,
34}
35
36impl DirtySpan {
37    /// Create a new dirty span.
38    pub fn new(start: u16, end: u16) -> Self {
39        Self { start, end }
40    }
41
42    /// Number of columns in this span.
43    #[inline]
44    pub fn len(&self) -> u16 {
45        self.end.saturating_sub(self.start)
46    }
47
48    /// Whether this span is empty.
49    #[inline]
50    pub fn is_empty(&self) -> bool {
51        self.end <= self.start
52    }
53
54    /// Whether two spans overlap or are adjacent (gap <= merge_gap).
55    fn mergeable(&self, other: &Self, merge_gap: u16) -> bool {
56        // Saturating math avoids overflow if callers pass large spans/gaps.
57        let other_end = other.end.saturating_add(merge_gap);
58        let self_end = self.end.saturating_add(merge_gap);
59        !(self.start > other_end || other.start > self_end)
60    }
61
62    /// Merge another span into this one (union).
63    fn merge(&mut self, other: &Self) {
64        self.start = self.start.min(other.start);
65        self.end = self.end.max(other.end);
66    }
67}
68
69// ── Dirty row state ──────────────────────────────────────────────────
70
71/// Per-row dirty state: spans of modified columns.
72#[derive(Debug, Clone)]
73struct DirtyRow {
74    /// Whether the entire row is dirty (optimization: skip span check).
75    full: bool,
76    /// Sorted, non-overlapping dirty spans. Empty if `full` is true.
77    spans: Vec<DirtySpan>,
78}
79
80impl DirtyRow {
81    fn new() -> Self {
82        Self {
83            full: false,
84            spans: Vec::new(),
85        }
86    }
87
88    fn is_dirty(&self) -> bool {
89        self.full || !self.spans.is_empty()
90    }
91
92    fn mark_full(&mut self) {
93        self.full = true;
94        self.spans.clear();
95    }
96
97    fn mark_span(&mut self, start: u16, end: u16, merge_gap: u16) {
98        if self.full {
99            return;
100        }
101        let new_span = DirtySpan::new(start, end);
102        // Try to merge with existing spans.
103        let mut merged = false;
104        for span in &mut self.spans {
105            if span.mergeable(&new_span, merge_gap) {
106                span.merge(&new_span);
107                merged = true;
108                break;
109            }
110        }
111        if !merged {
112            self.spans.push(new_span);
113        }
114        // Re-sort and coalesce after insertion.
115        self.coalesce(merge_gap);
116    }
117
118    fn coalesce(&mut self, merge_gap: u16) {
119        if self.spans.len() < 2 {
120            return;
121        }
122        self.spans.sort_by_key(|s| s.start);
123        let mut write = 0;
124        for read in 1..self.spans.len() {
125            if self.spans[write].mergeable(&self.spans[read], merge_gap) {
126                let other = self.spans[read];
127                self.spans[write].merge(&other);
128            } else {
129                write += 1;
130                self.spans[write] = self.spans[read];
131            }
132        }
133        self.spans.truncate(write + 1);
134    }
135
136    fn clear(&mut self) {
137        self.full = false;
138        self.spans.clear();
139    }
140}
141
142// ── Dirty tracker ────────────────────────────────────────────────────
143
144/// Tracks which cells have been modified since the last frame.
145///
146/// Used by `GridDiff::diff_dirty` to skip unchanged rows and focus only on
147/// dirty spans within changed rows, achieving sub-linear diff cost for
148/// typical workloads (1-5 rows changed per frame).
149///
150/// # Merge gap
151///
152/// When two dirty spans are separated by `merge_gap` or fewer clean cells,
153/// they are merged into a single span. This reduces overhead from many
154/// tiny spans (e.g., character-by-character typing). Default: 1.
155#[derive(Debug, Clone)]
156pub struct DirtyTracker {
157    rows: Vec<DirtyRow>,
158    cols: u16,
159    merge_gap: u16,
160    /// Number of dirty rows (cached for fast "any dirty?" checks).
161    dirty_count: usize,
162}
163
164impl DirtyTracker {
165    /// Create a new tracker for a grid of the given dimensions.
166    pub fn new(cols: u16, rows: u16) -> Self {
167        Self {
168            rows: (0..rows).map(|_| DirtyRow::new()).collect(),
169            cols,
170            merge_gap: 1,
171            dirty_count: 0,
172        }
173    }
174
175    /// Number of rows tracked.
176    pub fn row_count(&self) -> u16 {
177        self.rows.len() as u16
178    }
179
180    /// Set the merge gap for span coalescing.
181    pub fn set_merge_gap(&mut self, gap: u16) {
182        self.merge_gap = gap;
183    }
184
185    /// Whether any cell is dirty.
186    pub fn is_dirty(&self) -> bool {
187        self.dirty_count > 0
188    }
189
190    /// Number of dirty rows.
191    pub fn dirty_row_count(&self) -> usize {
192        self.dirty_count
193    }
194
195    /// Mark a single cell as dirty.
196    pub fn mark_cell(&mut self, row: u16, col: u16) {
197        if col >= self.cols {
198            return;
199        }
200        if let Some(dr) = self.rows.get_mut(row as usize) {
201            let was_dirty = dr.is_dirty();
202            let end = col.saturating_add(1).min(self.cols);
203            dr.mark_span(col, end, self.merge_gap);
204            if !was_dirty && dr.is_dirty() {
205                self.dirty_count += 1;
206            }
207        }
208    }
209
210    /// Mark a horizontal range `[start_col, end_col)` as dirty.
211    pub fn mark_span(&mut self, row: u16, start_col: u16, end_col: u16) {
212        if start_col >= self.cols || end_col <= start_col {
213            return;
214        }
215        let end = end_col.min(self.cols);
216        if end <= start_col {
217            return;
218        }
219        if let Some(dr) = self.rows.get_mut(row as usize) {
220            let was_dirty = dr.is_dirty();
221            dr.mark_span(start_col, end, self.merge_gap);
222            if !was_dirty && dr.is_dirty() {
223                self.dirty_count += 1;
224            }
225        }
226    }
227
228    /// Mark an entire row as dirty.
229    pub fn mark_row(&mut self, row: u16) {
230        if let Some(dr) = self.rows.get_mut(row as usize) {
231            let was_dirty = dr.is_dirty();
232            dr.mark_full();
233            if !was_dirty {
234                self.dirty_count += 1;
235            }
236        }
237    }
238
239    /// Mark all rows as dirty (forces full redraw).
240    pub fn mark_all(&mut self) {
241        for dr in &mut self.rows {
242            dr.mark_full();
243        }
244        self.dirty_count = self.rows.len();
245    }
246
247    /// Clear all dirty state for the next frame.
248    pub fn clear(&mut self) {
249        for dr in &mut self.rows {
250            dr.clear();
251        }
252        self.dirty_count = 0;
253    }
254
255    /// Whether a specific row is dirty.
256    pub fn is_row_dirty(&self, row: u16) -> bool {
257        self.rows.get(row as usize).is_some_and(|dr| dr.is_dirty())
258    }
259
260    /// Get dirty spans for a row.
261    ///
262    /// Returns `None` if the entire row is dirty (caller should scan all columns).
263    /// Returns `Some(&[DirtySpan])` for partial dirty rows.
264    /// Returns `Some(&[])` for clean rows.
265    pub fn row_spans(&self, row: u16) -> Option<&[DirtySpan]> {
266        self.rows.get(row as usize).and_then(|dr| {
267            if dr.full {
268                None // entire row dirty — scan all columns
269            } else {
270                Some(dr.spans.as_slice())
271            }
272        })
273    }
274
275    /// Resize the tracker for a new grid size.
276    pub fn resize(&mut self, new_cols: u16, new_rows: u16) {
277        self.cols = new_cols;
278        self.rows.resize_with(new_rows as usize, DirtyRow::new);
279        // Mark everything dirty after resize.
280        self.mark_all();
281    }
282}
283
284// ── Change run ───────────────────────────────────────────────────────
285
286/// A contiguous horizontal span of changed cells on a single row.
287///
288/// Used by native presenters for efficient cursor positioning:
289/// instead of per-cell cursor moves, emit runs and minimize ANSI overhead.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct ChangeRun {
292    /// Row index.
293    pub row: u16,
294    /// Start column (inclusive).
295    pub start_col: u16,
296    /// End column (exclusive).
297    pub end_col: u16,
298}
299
300impl ChangeRun {
301    /// Number of cells in this run.
302    #[inline]
303    pub fn len(&self) -> u16 {
304        self.end_col.saturating_sub(self.start_col)
305    }
306
307    /// Whether this run is empty.
308    #[inline]
309    pub fn is_empty(&self) -> bool {
310        self.end_col <= self.start_col
311    }
312}
313
314// ── Cell update ──────────────────────────────────────────────────────
315
316/// A single cell update at (row, col).
317///
318/// Used by GPU renderers for per-instance upload and by trace replayers
319/// for exact state reconstruction.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct CellUpdate {
322    pub row: u16,
323    pub col: u16,
324    pub cell: Cell,
325}
326
327// ── Patch ────────────────────────────────────────────────────────────
328
329/// A minimal set of changes between two grid states.
330///
331/// Updates are stored in **row-major order** (row ascending, column ascending).
332/// This ordering is stable and deterministic for identical input grids.
333#[derive(Debug, Clone, PartialEq, Eq, Default)]
334pub struct Patch {
335    /// Grid width at the time the patch was computed.
336    pub cols: u16,
337    /// Grid height at the time the patch was computed.
338    pub rows: u16,
339    /// Individual cell updates in row-major order.
340    pub updates: Vec<CellUpdate>,
341}
342
343impl Patch {
344    /// Create an empty patch for a given grid shape.
345    #[must_use]
346    pub fn new(cols: u16, rows: u16) -> Self {
347        Self {
348            cols,
349            rows,
350            updates: Vec::new(),
351        }
352    }
353
354    /// Whether the patch has no updates.
355    #[inline]
356    #[must_use]
357    pub fn is_empty(&self) -> bool {
358        self.updates.is_empty()
359    }
360
361    /// Number of changed cells.
362    #[inline]
363    #[must_use]
364    pub fn len(&self) -> usize {
365        self.updates.len()
366    }
367
368    /// Append a single cell update.
369    pub fn push(&mut self, row: u16, col: u16, cell: Cell) {
370        self.updates.push(CellUpdate { row, col, cell });
371    }
372
373    /// Coalesce updates into contiguous horizontal runs.
374    ///
375    /// Runs are in row-major order. Adjacent columns on the same row are
376    /// merged into a single run.
377    #[must_use]
378    pub fn runs(&self) -> Vec<ChangeRun> {
379        let mut runs = Vec::new();
380        self.runs_into(&mut runs);
381        runs
382    }
383
384    /// Coalesce updates into runs, appending to the provided buffer.
385    ///
386    /// Reuse the buffer across frames to avoid allocation.
387    pub fn runs_into(&self, out: &mut Vec<ChangeRun>) {
388        out.clear();
389        if self.updates.is_empty() {
390            return;
391        }
392
393        let mut current_row = self.updates[0].row;
394        let mut start_col = self.updates[0].col;
395        let mut end_col = self.updates[0].col.saturating_add(1);
396
397        for update in &self.updates[1..] {
398            if update.row == current_row && update.col == end_col {
399                // Extend current run.
400                end_col = update.col.saturating_add(1);
401            } else {
402                // Emit current run.
403                out.push(ChangeRun {
404                    row: current_row,
405                    start_col,
406                    end_col,
407                });
408                current_row = update.row;
409                start_col = update.col;
410                end_col = update.col.saturating_add(1);
411            }
412        }
413        // Emit final run.
414        out.push(ChangeRun {
415            row: current_row,
416            start_col,
417            end_col,
418        });
419    }
420
421    /// Ratio of changed cells to total grid cells.
422    ///
423    /// Returns 0.0 for empty grids.
424    #[must_use]
425    pub fn density(&self) -> f64 {
426        let total = self.cols as usize * self.rows as usize;
427        if total == 0 {
428            return 0.0;
429        }
430        self.updates.len() as f64 / total as f64
431    }
432}
433
434// ── Grid diff ────────────────────────────────────────────────────────
435
436/// Compute diffs between two grids.
437pub struct GridDiff;
438
439impl GridDiff {
440    /// Full diff: compare every cell between `old` and `new`.
441    ///
442    /// O(rows * cols). Use `diff_dirty` for typical sub-linear performance.
443    pub fn diff(old: &Grid, new: &Grid) -> Patch {
444        let cols = new.cols();
445        let rows = new.rows();
446        let mut patch = Patch::new(cols, rows);
447
448        for r in 0..rows {
449            for c in 0..cols {
450                let old_cell = old.cell(r, c);
451                let new_cell = new.cell(r, c);
452                match (old_cell, new_cell) {
453                    (Some(o), Some(n)) if o != n => {
454                        patch.push(r, c, *n);
455                    }
456                    (None, Some(n)) => {
457                        patch.push(r, c, *n);
458                    }
459                    _ => {}
460                }
461            }
462        }
463
464        patch
465    }
466
467    /// Dirty-hinted diff: only compare cells in dirty rows/spans.
468    ///
469    /// Skips clean rows entirely and within dirty rows only checks the
470    /// indicated spans. This achieves sub-linear performance for typical
471    /// workloads where only 1-5 rows change per frame.
472    ///
473    /// **Soundness**: the caller must ensure the `DirtyTracker` has been
474    /// correctly maintained (all modified cells are marked dirty). If a cell
475    /// was modified but not marked, the change will be missed.
476    pub fn diff_dirty(old: &Grid, new: &Grid, tracker: &DirtyTracker) -> Patch {
477        let cols = new.cols();
478        let rows = new.rows();
479        let mut patch = Patch::new(cols, rows);
480
481        if !tracker.is_dirty() {
482            return patch;
483        }
484
485        for r in 0..rows {
486            if !tracker.is_row_dirty(r) {
487                continue;
488            }
489
490            match tracker.row_spans(r) {
491                None => {
492                    // Entire row is dirty — scan all columns.
493                    for c in 0..cols {
494                        let old_cell = old.cell(r, c);
495                        let new_cell = new.cell(r, c);
496                        match (old_cell, new_cell) {
497                            (Some(o), Some(n)) if o != n => {
498                                patch.push(r, c, *n);
499                            }
500                            (None, Some(n)) => {
501                                patch.push(r, c, *n);
502                            }
503                            _ => {}
504                        }
505                    }
506                }
507                Some(spans) => {
508                    // Only check dirty spans.
509                    for span in spans {
510                        let start = span.start.min(cols);
511                        let end = span.end.min(cols);
512                        for c in start..end {
513                            let old_cell = old.cell(r, c);
514                            let new_cell = new.cell(r, c);
515                            match (old_cell, new_cell) {
516                                (Some(o), Some(n)) if o != n => {
517                                    patch.push(r, c, *n);
518                                }
519                                (None, Some(n)) => {
520                                    patch.push(r, c, *n);
521                                }
522                                _ => {}
523                            }
524                        }
525                    }
526                }
527            }
528        }
529
530        patch
531    }
532
533    /// Quick full-diff into a reusable patch (avoids allocation).
534    pub fn diff_into(old: &Grid, new: &Grid, patch: &mut Patch) {
535        patch.cols = new.cols();
536        patch.rows = new.rows();
537        patch.updates.clear();
538
539        let cols = new.cols();
540        let rows = new.rows();
541
542        for r in 0..rows {
543            for c in 0..cols {
544                let old_cell = old.cell(r, c);
545                let new_cell = new.cell(r, c);
546                match (old_cell, new_cell) {
547                    (Some(o), Some(n)) if o != n => {
548                        patch.push(r, c, *n);
549                    }
550                    (None, Some(n)) => {
551                        patch.push(r, c, *n);
552                    }
553                    _ => {}
554                }
555            }
556        }
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    // ── Patch basics ─────────────────────────────────────────────────
564
565    #[test]
566    fn empty_patch_is_empty() {
567        let p = Patch::new(80, 24);
568        assert!(p.is_empty());
569        assert_eq!(p.len(), 0);
570        assert_eq!(p.cols, 80);
571        assert_eq!(p.rows, 24);
572    }
573
574    #[test]
575    fn push_adds_update() {
576        let mut p = Patch::new(2, 2);
577        p.push(0, 1, Cell::new('X'));
578        assert_eq!(p.len(), 1);
579        assert_eq!(p.updates[0].row, 0);
580        assert_eq!(p.updates[0].col, 1);
581        assert_eq!(p.updates[0].cell.content(), 'X');
582    }
583
584    #[test]
585    fn density_calculation() {
586        let mut p = Patch::new(10, 10);
587        for i in 0..25u16 {
588            p.push(i / 10, i % 10, Cell::new('X'));
589        }
590        let d = p.density();
591        assert!((d - 0.25).abs() < f64::EPSILON);
592    }
593
594    #[test]
595    fn density_empty_grid() {
596        let p = Patch::new(0, 0);
597        assert_eq!(p.density(), 0.0);
598    }
599
600    // ── Run coalescing ───────────────────────────────────────────────
601
602    #[test]
603    fn runs_empty_patch() {
604        let p = Patch::new(10, 5);
605        assert!(p.runs().is_empty());
606    }
607
608    #[test]
609    fn runs_single_cell() {
610        let mut p = Patch::new(10, 5);
611        p.push(2, 5, Cell::new('A'));
612        let runs = p.runs();
613        assert_eq!(runs.len(), 1);
614        assert_eq!(runs[0].row, 2);
615        assert_eq!(runs[0].start_col, 5);
616        assert_eq!(runs[0].end_col, 6);
617        assert_eq!(runs[0].len(), 1);
618    }
619
620    #[test]
621    fn runs_coalesces_adjacent_cells() {
622        let mut p = Patch::new(10, 5);
623        p.push(1, 3, Cell::new('A'));
624        p.push(1, 4, Cell::new('B'));
625        p.push(1, 5, Cell::new('C'));
626        let runs = p.runs();
627        assert_eq!(runs.len(), 1);
628        assert_eq!(runs[0].row, 1);
629        assert_eq!(runs[0].start_col, 3);
630        assert_eq!(runs[0].end_col, 6);
631        assert_eq!(runs[0].len(), 3);
632    }
633
634    #[test]
635    fn runs_gap_creates_separate_runs() {
636        let mut p = Patch::new(10, 5);
637        p.push(0, 1, Cell::new('A'));
638        p.push(0, 5, Cell::new('B'));
639        let runs = p.runs();
640        assert_eq!(runs.len(), 2);
641        assert_eq!(runs[0].start_col, 1);
642        assert_eq!(runs[0].end_col, 2);
643        assert_eq!(runs[1].start_col, 5);
644        assert_eq!(runs[1].end_col, 6);
645    }
646
647    #[test]
648    fn runs_different_rows() {
649        let mut p = Patch::new(10, 5);
650        p.push(0, 0, Cell::new('A'));
651        p.push(0, 1, Cell::new('B'));
652        p.push(2, 3, Cell::new('C'));
653        let runs = p.runs();
654        assert_eq!(runs.len(), 2);
655        assert_eq!(runs[0].row, 0);
656        assert_eq!(runs[0].start_col, 0);
657        assert_eq!(runs[0].end_col, 2);
658        assert_eq!(runs[1].row, 2);
659        assert_eq!(runs[1].start_col, 3);
660        assert_eq!(runs[1].end_col, 4);
661    }
662
663    #[test]
664    fn runs_into_reuses_buffer() {
665        let mut p = Patch::new(10, 5);
666        p.push(0, 0, Cell::new('A'));
667        p.push(0, 1, Cell::new('B'));
668
669        let mut buf = Vec::new();
670        p.runs_into(&mut buf);
671        assert_eq!(buf.len(), 1);
672        // Reuse same buffer.
673        p.runs_into(&mut buf);
674        assert_eq!(buf.len(), 1); // cleared and refilled
675    }
676
677    // ── DirtyTracker ─────────────────────────────────────────────────
678
679    #[test]
680    fn tracker_new_is_clean() {
681        let t = DirtyTracker::new(80, 24);
682        assert!(!t.is_dirty());
683        assert_eq!(t.dirty_row_count(), 0);
684        assert_eq!(t.row_count(), 24);
685    }
686
687    #[test]
688    fn tracker_mark_cell() {
689        let mut t = DirtyTracker::new(80, 24);
690        t.mark_cell(5, 10);
691        assert!(t.is_dirty());
692        assert!(t.is_row_dirty(5));
693        assert!(!t.is_row_dirty(4));
694        assert_eq!(t.dirty_row_count(), 1);
695
696        let spans = t.row_spans(5).unwrap();
697        assert_eq!(spans.len(), 1);
698        assert_eq!(spans[0].start, 10);
699        assert_eq!(spans[0].end, 11);
700    }
701
702    #[test]
703    fn tracker_mark_span() {
704        let mut t = DirtyTracker::new(80, 24);
705        t.mark_span(3, 5, 15);
706        let spans = t.row_spans(3).unwrap();
707        assert_eq!(spans.len(), 1);
708        assert_eq!(spans[0].start, 5);
709        assert_eq!(spans[0].end, 15);
710    }
711
712    #[test]
713    fn tracker_mark_row() {
714        let mut t = DirtyTracker::new(80, 24);
715        t.mark_row(10);
716        assert!(t.is_row_dirty(10));
717        // Full row → row_spans returns None.
718        assert!(t.row_spans(10).is_none());
719    }
720
721    #[test]
722    fn tracker_mark_all() {
723        let mut t = DirtyTracker::new(80, 24);
724        t.mark_all();
725        assert_eq!(t.dirty_row_count(), 24);
726        for r in 0..24 {
727            assert!(t.is_row_dirty(r));
728        }
729    }
730
731    #[test]
732    fn tracker_clear() {
733        let mut t = DirtyTracker::new(80, 24);
734        t.mark_all();
735        t.clear();
736        assert!(!t.is_dirty());
737        assert_eq!(t.dirty_row_count(), 0);
738    }
739
740    #[test]
741    fn tracker_adjacent_spans_merge() {
742        let mut t = DirtyTracker::new(80, 24);
743        t.mark_span(0, 5, 10);
744        t.mark_span(0, 10, 15); // adjacent to previous
745        let spans = t.row_spans(0).unwrap();
746        assert_eq!(spans.len(), 1);
747        assert_eq!(spans[0].start, 5);
748        assert_eq!(spans[0].end, 15);
749    }
750
751    #[test]
752    fn tracker_gap_within_merge_gap() {
753        let mut t = DirtyTracker::new(80, 24);
754        t.set_merge_gap(2);
755        t.mark_span(0, 5, 8);
756        t.mark_span(0, 10, 15); // gap of 2 → should merge
757        let spans = t.row_spans(0).unwrap();
758        assert_eq!(spans.len(), 1);
759        assert_eq!(spans[0].start, 5);
760        assert_eq!(spans[0].end, 15);
761    }
762
763    #[test]
764    fn tracker_gap_beyond_merge_gap() {
765        let mut t = DirtyTracker::new(80, 24);
766        t.set_merge_gap(1);
767        t.mark_span(0, 5, 8);
768        t.mark_span(0, 20, 25); // gap of 12 → separate
769        let spans = t.row_spans(0).unwrap();
770        assert_eq!(spans.len(), 2);
771    }
772
773    #[test]
774    fn tracker_out_of_bounds_ignored() {
775        let mut t = DirtyTracker::new(80, 24);
776        t.mark_cell(99, 99);
777        assert!(!t.is_dirty());
778    }
779
780    #[test]
781    fn tracker_out_of_bounds_col_ignored() {
782        let mut t = DirtyTracker::new(80, 24);
783        t.mark_cell(0, u16::MAX);
784        t.mark_cell(0, 80); // col == cols
785        t.mark_span(0, 80, 81); // start == cols
786        t.mark_span(0, 10, 10); // empty span
787        assert!(!t.is_dirty());
788    }
789
790    #[test]
791    fn tracker_resize_marks_all_dirty() {
792        let mut t = DirtyTracker::new(80, 24);
793        t.resize(120, 40);
794        assert_eq!(t.row_count(), 40);
795        assert_eq!(t.dirty_row_count(), 40);
796    }
797
798    // ── GridDiff ─────────────────────────────────────────────────────
799
800    #[test]
801    fn diff_identical_grids_empty_patch() {
802        let a = Grid::new(10, 5);
803        let b = Grid::new(10, 5);
804        let patch = GridDiff::diff(&a, &b);
805        assert!(patch.is_empty());
806    }
807
808    #[test]
809    fn diff_detects_single_change() {
810        let a = Grid::new(10, 5);
811        let mut b = Grid::new(10, 5);
812        b.cell_mut(2, 3).unwrap().set_content('X', 1);
813        let patch = GridDiff::diff(&a, &b);
814        assert_eq!(patch.len(), 1);
815        assert_eq!(patch.updates[0].row, 2);
816        assert_eq!(patch.updates[0].col, 3);
817        assert_eq!(patch.updates[0].cell.content(), 'X');
818    }
819
820    #[test]
821    fn diff_detects_multiple_changes() {
822        let a = Grid::new(5, 3);
823        let mut b = Grid::new(5, 3);
824        b.cell_mut(0, 0).unwrap().set_content('A', 1);
825        b.cell_mut(1, 2).unwrap().set_content('B', 1);
826        b.cell_mut(2, 4).unwrap().set_content('C', 1);
827        let patch = GridDiff::diff(&a, &b);
828        assert_eq!(patch.len(), 3);
829        // Row-major order.
830        assert_eq!(patch.updates[0].row, 0);
831        assert_eq!(patch.updates[1].row, 1);
832        assert_eq!(patch.updates[2].row, 2);
833    }
834
835    #[test]
836    fn diff_into_reuses_patch() {
837        let a = Grid::new(5, 3);
838        let mut b = Grid::new(5, 3);
839        b.cell_mut(1, 1).unwrap().set_content('Z', 1);
840
841        let mut patch = Patch::new(0, 0);
842        GridDiff::diff_into(&a, &b, &mut patch);
843        assert_eq!(patch.len(), 1);
844        assert_eq!(patch.cols, 5);
845        assert_eq!(patch.rows, 3);
846    }
847
848    #[test]
849    fn diff_dirty_skips_clean_rows() {
850        let a = Grid::new(10, 5);
851        let mut b = Grid::new(10, 5);
852        // Change cells on rows 1 and 3.
853        b.cell_mut(1, 0).unwrap().set_content('A', 1);
854        b.cell_mut(3, 5).unwrap().set_content('B', 1);
855
856        // Only mark row 1 as dirty (not row 3).
857        let mut tracker = DirtyTracker::new(10, 5);
858        tracker.mark_row(1);
859
860        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
861        // Should only find the change on row 1.
862        assert_eq!(patch.len(), 1);
863        assert_eq!(patch.updates[0].row, 1);
864        assert_eq!(patch.updates[0].col, 0);
865    }
866
867    #[test]
868    fn diff_dirty_uses_spans() {
869        let a = Grid::new(10, 5);
870        let mut b = Grid::new(10, 5);
871        // Change at col 2 and col 8.
872        b.cell_mut(0, 2).unwrap().set_content('A', 1);
873        b.cell_mut(0, 8).unwrap().set_content('B', 1);
874
875        let mut tracker = DirtyTracker::new(10, 5);
876        // Only mark span [0, 5) — col 8 is outside.
877        tracker.mark_span(0, 0, 5);
878
879        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
880        // Only col 2 should be found (col 8 is outside the dirty span).
881        assert_eq!(patch.len(), 1);
882        assert_eq!(patch.updates[0].col, 2);
883    }
884
885    #[test]
886    fn diff_dirty_clean_tracker_empty_patch() {
887        let a = Grid::new(10, 5);
888        let mut b = Grid::new(10, 5);
889        b.cell_mut(0, 0).unwrap().set_content('X', 1);
890
891        let tracker = DirtyTracker::new(10, 5); // no dirty marks
892        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
893        assert!(patch.is_empty());
894    }
895
896    #[test]
897    fn diff_detects_attribute_changes() {
898        let a = Grid::new(5, 1);
899        let mut b = Grid::new(5, 1);
900        // Same content, different attrs.
901        let cell = b.cell_mut(0, 2).unwrap();
902        cell.attrs.flags = crate::cell::SgrFlags::BOLD;
903
904        let patch = GridDiff::diff(&a, &b);
905        assert_eq!(patch.len(), 1);
906        assert_eq!(patch.updates[0].col, 2);
907    }
908
909    #[test]
910    fn diff_row_major_order_guaranteed() {
911        let a = Grid::new(3, 3);
912        let mut b = Grid::new(3, 3);
913        // Add changes in reverse order.
914        b.cell_mut(2, 2).unwrap().set_content('Z', 1);
915        b.cell_mut(1, 1).unwrap().set_content('Y', 1);
916        b.cell_mut(0, 0).unwrap().set_content('X', 1);
917
918        let patch = GridDiff::diff(&a, &b);
919        assert_eq!(patch.len(), 3);
920        // Must be row-major.
921        assert!(patch.updates[0].row <= patch.updates[1].row);
922        assert!(patch.updates[1].row <= patch.updates[2].row);
923    }
924
925    // ── DirtySpan ────────────────────────────────────────────────────
926
927    #[test]
928    fn dirty_span_len() {
929        let span = DirtySpan::new(5, 10);
930        assert_eq!(span.len(), 5);
931        assert!(!span.is_empty());
932
933        let empty = DirtySpan::new(5, 5);
934        assert_eq!(empty.len(), 0);
935        assert!(empty.is_empty());
936    }
937
938    // ── ChangeRun ────────────────────────────────────────────────────
939
940    #[test]
941    fn change_run_len() {
942        let run = ChangeRun {
943            row: 0,
944            start_col: 3,
945            end_col: 7,
946        };
947        assert_eq!(run.len(), 4);
948        assert!(!run.is_empty());
949    }
950
951    // ── Integration: full pipeline ───────────────────────────────────
952
953    #[test]
954    fn pipeline_track_diff_coalesce() {
955        // Simulate: build new grid, track dirty, diff, coalesce to runs.
956        let old = Grid::new(10, 3);
957        let mut new = Grid::new(10, 3);
958        let mut tracker = DirtyTracker::new(10, 3);
959
960        // Simulate typing "hello" at row 1, cols 0-4.
961        for (i, ch) in "hello".chars().enumerate() {
962            new.cell_mut(1, i as u16).unwrap().set_content(ch, 1);
963            tracker.mark_cell(1, i as u16);
964        }
965
966        let patch = GridDiff::diff_dirty(&old, &new, &tracker);
967        assert_eq!(patch.len(), 5);
968
969        let runs = patch.runs();
970        assert_eq!(runs.len(), 1);
971        assert_eq!(runs[0].row, 1);
972        assert_eq!(runs[0].start_col, 0);
973        assert_eq!(runs[0].end_col, 5);
974        assert_eq!(runs[0].len(), 5);
975    }
976
977    #[test]
978    fn pipeline_sparse_changes() {
979        let old = Grid::new(80, 24);
980        let mut new = Grid::new(80, 24);
981        let mut tracker = DirtyTracker::new(80, 24);
982
983        // Simulate a status bar update at row 23 and cursor at row 5.
984        new.cell_mut(5, 10).unwrap().set_content('>', 1);
985        tracker.mark_cell(5, 10);
986
987        for c in 0..80u16 {
988            new.cell_mut(23, c).unwrap().set_content('-', 1);
989        }
990        tracker.mark_row(23);
991
992        let patch = GridDiff::diff_dirty(&old, &new, &tracker);
993        assert_eq!(patch.len(), 81); // 1 cursor + 80 status bar
994
995        let runs = patch.runs();
996        assert_eq!(runs.len(), 2); // one run on row 5, one on row 23
997        assert_eq!(runs[0].row, 5);
998        assert_eq!(runs[0].len(), 1);
999        assert_eq!(runs[1].row, 23);
1000        assert_eq!(runs[1].len(), 80);
1001
1002        // Density should be small (81 / 1920 ≈ 4.2%).
1003        assert!(patch.density() < 0.05);
1004    }
1005
1006    // ── Edge-case tests (bd-2iaas) ──────────────────────────────────
1007
1008    // ── DirtySpan edge cases ────────────────────────────────────────
1009
1010    #[test]
1011    fn dirty_span_reversed_is_empty() {
1012        let span = DirtySpan::new(10, 5);
1013        assert!(span.is_empty());
1014        assert_eq!(span.len(), 0);
1015    }
1016
1017    #[test]
1018    fn dirty_span_u16_max() {
1019        let span = DirtySpan::new(0, u16::MAX);
1020        assert_eq!(span.len(), u16::MAX);
1021        assert!(!span.is_empty());
1022    }
1023
1024    #[test]
1025    fn dirty_span_full_range() {
1026        let span = DirtySpan::new(0, u16::MAX);
1027        let other = DirtySpan::new(u16::MAX - 1, u16::MAX);
1028        // Should be mergeable with gap 0 since they're adjacent.
1029        assert!(span.mergeable(&other, 0));
1030    }
1031
1032    #[test]
1033    fn dirty_span_mergeable_exact_boundary() {
1034        // Spans [0,5) and [5,10) with merge_gap=0 → adjacent → mergeable.
1035        let a = DirtySpan::new(0, 5);
1036        let b = DirtySpan::new(5, 10);
1037        assert!(a.mergeable(&b, 0));
1038        assert!(b.mergeable(&a, 0));
1039    }
1040
1041    #[test]
1042    fn dirty_span_not_mergeable_gap_1_no_merge_gap() {
1043        // Spans [0,5) and [6,10) with merge_gap=0 → gap of 1 → not mergeable.
1044        let a = DirtySpan::new(0, 5);
1045        let b = DirtySpan::new(6, 10);
1046        assert!(!a.mergeable(&b, 0));
1047    }
1048
1049    #[test]
1050    fn dirty_span_mergeable_gap_1_with_merge_gap_1() {
1051        // Spans [0,5) and [6,10) with merge_gap=1 → gap of 1 → mergeable.
1052        let a = DirtySpan::new(0, 5);
1053        let b = DirtySpan::new(6, 10);
1054        assert!(a.mergeable(&b, 1));
1055    }
1056
1057    #[test]
1058    fn dirty_span_merge_union() {
1059        let mut a = DirtySpan::new(3, 7);
1060        let b = DirtySpan::new(5, 12);
1061        a.merge(&b);
1062        assert_eq!(a.start, 3);
1063        assert_eq!(a.end, 12);
1064    }
1065
1066    #[test]
1067    fn dirty_span_merge_disjoint() {
1068        let mut a = DirtySpan::new(0, 5);
1069        let b = DirtySpan::new(10, 20);
1070        a.merge(&b);
1071        // Union covers [0, 20) even if gap exists.
1072        assert_eq!(a.start, 0);
1073        assert_eq!(a.end, 20);
1074    }
1075
1076    #[test]
1077    fn dirty_span_merge_subset() {
1078        let mut a = DirtySpan::new(0, 20);
1079        let b = DirtySpan::new(5, 10);
1080        a.merge(&b);
1081        // Larger span unchanged.
1082        assert_eq!(a.start, 0);
1083        assert_eq!(a.end, 20);
1084    }
1085
1086    // ── DirtyTracker edge cases ─────────────────────────────────────
1087
1088    #[test]
1089    fn tracker_mark_cell_at_last_col() {
1090        let mut t = DirtyTracker::new(80, 24);
1091        t.mark_cell(0, 79); // cols - 1
1092        assert!(t.is_dirty());
1093        let spans = t.row_spans(0).unwrap();
1094        assert_eq!(spans.len(), 1);
1095        assert_eq!(spans[0].start, 79);
1096        assert_eq!(spans[0].end, 80);
1097    }
1098
1099    #[test]
1100    fn tracker_mark_span_clamped_to_cols() {
1101        let mut t = DirtyTracker::new(10, 1);
1102        t.mark_span(0, 5, 100); // end_col > cols → clamped to 10
1103        let spans = t.row_spans(0).unwrap();
1104        assert_eq!(spans.len(), 1);
1105        assert_eq!(spans[0].start, 5);
1106        assert_eq!(spans[0].end, 10);
1107    }
1108
1109    #[test]
1110    fn tracker_mark_row_then_mark_cell_stays_full() {
1111        let mut t = DirtyTracker::new(80, 24);
1112        t.mark_row(0);
1113        t.mark_cell(0, 10);
1114        // Row still full — mark_cell on full row is a no-op.
1115        assert!(t.row_spans(0).is_none());
1116        assert_eq!(t.dirty_row_count(), 1);
1117    }
1118
1119    #[test]
1120    fn tracker_mark_row_twice_no_double_count() {
1121        let mut t = DirtyTracker::new(80, 24);
1122        t.mark_row(5);
1123        t.mark_row(5);
1124        assert_eq!(t.dirty_row_count(), 1);
1125    }
1126
1127    #[test]
1128    fn tracker_mark_cell_twice_same_cell() {
1129        let mut t = DirtyTracker::new(80, 24);
1130        t.mark_cell(3, 10);
1131        t.mark_cell(3, 10);
1132        assert_eq!(t.dirty_row_count(), 1);
1133        let spans = t.row_spans(3).unwrap();
1134        assert_eq!(spans.len(), 1);
1135    }
1136
1137    #[test]
1138    fn tracker_resize_to_zero_rows() {
1139        let mut t = DirtyTracker::new(80, 24);
1140        t.resize(80, 0);
1141        assert_eq!(t.row_count(), 0);
1142        assert_eq!(t.dirty_row_count(), 0);
1143        assert!(!t.is_dirty());
1144    }
1145
1146    #[test]
1147    fn tracker_resize_to_zero_cols() {
1148        let mut t = DirtyTracker::new(80, 24);
1149        t.resize(0, 24);
1150        assert_eq!(t.row_count(), 24);
1151        // All rows marked dirty by resize.
1152        assert_eq!(t.dirty_row_count(), 24);
1153    }
1154
1155    #[test]
1156    fn tracker_clear_then_mark_cell() {
1157        let mut t = DirtyTracker::new(80, 24);
1158        t.mark_all();
1159        t.clear();
1160        t.mark_cell(0, 0);
1161        assert_eq!(t.dirty_row_count(), 1);
1162        // Row 0 should have spans, not full.
1163        let spans = t.row_spans(0).unwrap();
1164        assert_eq!(spans.len(), 1);
1165    }
1166
1167    #[test]
1168    fn tracker_clone_independence() {
1169        let mut t = DirtyTracker::new(80, 24);
1170        t.mark_cell(0, 5);
1171        let mut t2 = t.clone();
1172        t2.mark_cell(1, 10);
1173        assert_eq!(t.dirty_row_count(), 1);
1174        assert_eq!(t2.dirty_row_count(), 2);
1175    }
1176
1177    #[test]
1178    fn tracker_row_spans_out_of_bounds() {
1179        let t = DirtyTracker::new(80, 24);
1180        assert_eq!(t.row_spans(24), None);
1181        assert_eq!(t.row_spans(100), None);
1182    }
1183
1184    #[test]
1185    fn tracker_is_row_dirty_out_of_bounds() {
1186        let t = DirtyTracker::new(80, 24);
1187        assert!(!t.is_row_dirty(24));
1188        assert!(!t.is_row_dirty(u16::MAX));
1189    }
1190
1191    #[test]
1192    fn tracker_multiple_spans_coalesce_three_way() {
1193        let mut t = DirtyTracker::new(80, 24);
1194        t.set_merge_gap(0);
1195        t.mark_span(0, 0, 3);
1196        t.mark_span(0, 6, 9);
1197        // Now add a span that bridges the gap.
1198        t.mark_span(0, 3, 6);
1199        let spans = t.row_spans(0).unwrap();
1200        assert_eq!(spans.len(), 1);
1201        assert_eq!(spans[0].start, 0);
1202        assert_eq!(spans[0].end, 9);
1203    }
1204
1205    #[test]
1206    fn tracker_mark_span_empty_range_no_op() {
1207        let mut t = DirtyTracker::new(80, 24);
1208        t.mark_span(0, 5, 5); // start == end
1209        assert!(!t.is_dirty());
1210    }
1211
1212    #[test]
1213    fn tracker_mark_span_reversed_range_no_op() {
1214        let mut t = DirtyTracker::new(80, 24);
1215        t.mark_span(0, 10, 5); // start > end
1216        assert!(!t.is_dirty());
1217    }
1218
1219    #[test]
1220    fn tracker_resize_shrink_then_grow() {
1221        let mut t = DirtyTracker::new(80, 24);
1222        t.mark_cell(20, 0);
1223        t.resize(80, 10); // Shrinks — row 20 gone.
1224        assert_eq!(t.row_count(), 10);
1225        t.clear();
1226        t.resize(80, 30); // Grows.
1227        assert_eq!(t.row_count(), 30);
1228        assert_eq!(t.dirty_row_count(), 30); // Resize marks all dirty.
1229    }
1230
1231    // ── ChangeRun edge cases ────────────────────────────────────────
1232
1233    #[test]
1234    fn change_run_empty() {
1235        let run = ChangeRun {
1236            row: 0,
1237            start_col: 5,
1238            end_col: 5,
1239        };
1240        assert!(run.is_empty());
1241        assert_eq!(run.len(), 0);
1242    }
1243
1244    #[test]
1245    fn change_run_single_cell() {
1246        let run = ChangeRun {
1247            row: 0,
1248            start_col: 0,
1249            end_col: 1,
1250        };
1251        assert!(!run.is_empty());
1252        assert_eq!(run.len(), 1);
1253    }
1254
1255    #[test]
1256    fn change_run_clone_and_eq() {
1257        let run = ChangeRun {
1258            row: 5,
1259            start_col: 10,
1260            end_col: 20,
1261        };
1262        let run2 = run.clone();
1263        assert_eq!(run, run2);
1264    }
1265
1266    // ── Patch edge cases ────────────────────────────────────────────
1267
1268    #[test]
1269    fn patch_default_trait() {
1270        let p = Patch::default();
1271        assert_eq!(p.cols, 0);
1272        assert_eq!(p.rows, 0);
1273        assert!(p.is_empty());
1274    }
1275
1276    #[test]
1277    fn patch_density_full_grid() {
1278        let mut p = Patch::new(2, 2);
1279        for r in 0..2u16 {
1280            for c in 0..2u16 {
1281                p.push(r, c, Cell::new('X'));
1282            }
1283        }
1284        assert!((p.density() - 1.0).abs() < f64::EPSILON);
1285    }
1286
1287    #[test]
1288    fn patch_density_zero_cols_nonzero_rows() {
1289        let p = Patch::new(0, 10);
1290        assert_eq!(p.density(), 0.0);
1291    }
1292
1293    #[test]
1294    fn patch_density_nonzero_cols_zero_rows() {
1295        let p = Patch::new(10, 0);
1296        assert_eq!(p.density(), 0.0);
1297    }
1298
1299    #[test]
1300    fn patch_runs_single_update() {
1301        let mut p = Patch::new(80, 24);
1302        p.push(12, 40, Cell::new('Z'));
1303        let runs = p.runs();
1304        assert_eq!(runs.len(), 1);
1305        assert_eq!(runs[0].row, 12);
1306        assert_eq!(runs[0].start_col, 40);
1307        assert_eq!(runs[0].end_col, 41);
1308    }
1309
1310    #[test]
1311    fn patch_runs_gap_of_one_splits() {
1312        let mut p = Patch::new(10, 1);
1313        p.push(0, 0, Cell::new('A'));
1314        // col 1 is skipped
1315        p.push(0, 2, Cell::new('B'));
1316        let runs = p.runs();
1317        assert_eq!(runs.len(), 2);
1318        assert_eq!(runs[0].end_col, 1);
1319        assert_eq!(runs[1].start_col, 2);
1320    }
1321
1322    #[test]
1323    fn patch_runs_into_clears_buffer() {
1324        let mut p = Patch::new(10, 1);
1325        p.push(0, 0, Cell::new('A'));
1326
1327        let mut buf = vec![ChangeRun {
1328            row: 99,
1329            start_col: 99,
1330            end_col: 100,
1331        }];
1332        p.runs_into(&mut buf);
1333        // Buffer should have been cleared and contain only the new run.
1334        assert_eq!(buf.len(), 1);
1335        assert_eq!(buf[0].row, 0);
1336    }
1337
1338    #[test]
1339    fn patch_clone_and_eq() {
1340        let mut p = Patch::new(5, 5);
1341        p.push(0, 0, Cell::new('X'));
1342        let p2 = p.clone();
1343        assert_eq!(p, p2);
1344    }
1345
1346    #[test]
1347    fn patch_runs_three_rows_one_cell_each() {
1348        let mut p = Patch::new(80, 24);
1349        p.push(0, 10, Cell::new('A'));
1350        p.push(5, 20, Cell::new('B'));
1351        p.push(10, 30, Cell::new('C'));
1352        let runs = p.runs();
1353        assert_eq!(runs.len(), 3);
1354        for run in &runs {
1355            assert_eq!(run.len(), 1);
1356        }
1357    }
1358
1359    // ── GridDiff edge cases ─────────────────────────────────────────
1360
1361    #[test]
1362    fn diff_old_smaller_than_new() {
1363        let old = Grid::new(5, 3);
1364        let mut new = Grid::new(10, 5);
1365        new.cell_mut(4, 9).unwrap().set_content('Z', 1);
1366        let patch = GridDiff::diff(&old, &new);
1367        // Cells beyond old's bounds (old returns None, new returns Some) → changes.
1368        assert!(!patch.is_empty());
1369        // Should include the cell at (4,9) at minimum.
1370        assert!(patch.updates.iter().any(|u| u.row == 4 && u.col == 9));
1371    }
1372
1373    #[test]
1374    fn diff_new_smaller_than_old() {
1375        let mut old = Grid::new(10, 5);
1376        old.cell_mut(4, 9).unwrap().set_content('A', 1);
1377        let new = Grid::new(5, 3);
1378        // Diff uses new's dimensions, so (4,9) is out of range.
1379        let patch = GridDiff::diff(&old, &new);
1380        assert_eq!(patch.cols, 5);
1381        assert_eq!(patch.rows, 3);
1382        // No update at (4,9) since it's beyond new's bounds.
1383        assert!(patch.updates.iter().all(|u| u.row < 3 && u.col < 5));
1384    }
1385
1386    #[test]
1387    fn diff_into_called_twice() {
1388        let a = Grid::new(5, 3);
1389        let mut b = Grid::new(5, 3);
1390        b.cell_mut(0, 0).unwrap().set_content('X', 1);
1391
1392        let mut patch = Patch::new(0, 0);
1393        GridDiff::diff_into(&a, &b, &mut patch);
1394        assert_eq!(patch.len(), 1);
1395
1396        // Second call with identical grids → patch should be empty.
1397        GridDiff::diff_into(&b, &b, &mut patch);
1398        assert_eq!(patch.len(), 0);
1399        assert_eq!(patch.cols, 5);
1400    }
1401
1402    #[test]
1403    fn diff_dirty_multiple_spans_same_row() {
1404        let a = Grid::new(20, 1);
1405        let mut b = Grid::new(20, 1);
1406        b.cell_mut(0, 2).unwrap().set_content('A', 1);
1407        b.cell_mut(0, 15).unwrap().set_content('B', 1);
1408
1409        let mut tracker = DirtyTracker::new(20, 1);
1410        tracker.set_merge_gap(0);
1411        tracker.mark_span(0, 0, 5);
1412        tracker.mark_span(0, 12, 18);
1413
1414        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
1415        assert_eq!(patch.len(), 2);
1416        assert_eq!(patch.updates[0].col, 2);
1417        assert_eq!(patch.updates[1].col, 15);
1418    }
1419
1420    #[test]
1421    fn diff_dirty_full_row_vs_span_row() {
1422        let a = Grid::new(10, 3);
1423        let mut b = Grid::new(10, 3);
1424        // Row 0: full dirty, one change.
1425        b.cell_mut(0, 5).unwrap().set_content('A', 1);
1426        // Row 2: span dirty, one change.
1427        b.cell_mut(2, 3).unwrap().set_content('B', 1);
1428
1429        let mut tracker = DirtyTracker::new(10, 3);
1430        tracker.mark_row(0);
1431        tracker.mark_span(2, 0, 5);
1432
1433        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
1434        assert_eq!(patch.len(), 2);
1435        assert_eq!(patch.updates[0].row, 0);
1436        assert_eq!(patch.updates[1].row, 2);
1437    }
1438
1439    #[test]
1440    fn diff_all_cells_changed() {
1441        let a = Grid::new(3, 3);
1442        let mut b = Grid::new(3, 3);
1443        for r in 0..3u16 {
1444            for c in 0..3u16 {
1445                b.cell_mut(r, c).unwrap().set_content('X', 1);
1446            }
1447        }
1448        let patch = GridDiff::diff(&a, &b);
1449        assert_eq!(patch.len(), 9);
1450        assert!((patch.density() - 1.0).abs() < f64::EPSILON);
1451        let runs = patch.runs();
1452        assert_eq!(runs.len(), 3); // One run per row.
1453        for run in &runs {
1454            assert_eq!(run.len(), 3);
1455        }
1456    }
1457
1458    #[test]
1459    fn diff_dirty_span_beyond_grid_cols() {
1460        let a = Grid::new(5, 1);
1461        let mut b = Grid::new(5, 1);
1462        b.cell_mut(0, 3).unwrap().set_content('Z', 1);
1463
1464        let mut tracker = DirtyTracker::new(5, 1);
1465        // Mark a span that extends beyond grid cols.
1466        tracker.mark_span(0, 0, 100);
1467        let patch = GridDiff::diff_dirty(&a, &b, &tracker);
1468        assert_eq!(patch.len(), 1);
1469        assert_eq!(patch.updates[0].col, 3);
1470    }
1471
1472    // ── CellUpdate edge cases ───────────────────────────────────────
1473
1474    #[test]
1475    fn cell_update_clone_and_eq() {
1476        let u = CellUpdate {
1477            row: 5,
1478            col: 10,
1479            cell: Cell::new('X'),
1480        };
1481        let u2 = u.clone();
1482        assert_eq!(u, u2);
1483        assert_eq!(u.row, u2.row);
1484        assert_eq!(u.col, u2.col);
1485    }
1486
1487    #[test]
1488    fn cell_update_debug_format() {
1489        let u = CellUpdate {
1490            row: 0,
1491            col: 0,
1492            cell: Cell::new('A'),
1493        };
1494        let dbg = format!("{u:?}");
1495        assert!(dbg.contains("CellUpdate"));
1496    }
1497
1498    // ── DirtyTracker + merge_gap interactions ───────────────────────
1499
1500    #[test]
1501    fn tracker_merge_gap_zero_keeps_separate() {
1502        let mut t = DirtyTracker::new(80, 24);
1503        t.set_merge_gap(0);
1504        t.mark_span(0, 0, 5);
1505        t.mark_span(0, 5, 10); // adjacent at col 5
1506        let spans = t.row_spans(0).unwrap();
1507        // With merge_gap=0, adjacent spans [0,5) and [5,10) should merge.
1508        assert_eq!(spans.len(), 1);
1509        assert_eq!(spans[0].start, 0);
1510        assert_eq!(spans[0].end, 10);
1511    }
1512
1513    #[test]
1514    fn tracker_merge_gap_large_merges_everything() {
1515        let mut t = DirtyTracker::new(80, 24);
1516        t.set_merge_gap(u16::MAX);
1517        t.mark_span(0, 0, 5);
1518        t.mark_span(0, 70, 80);
1519        let spans = t.row_spans(0).unwrap();
1520        // Large merge gap should merge everything.
1521        assert_eq!(spans.len(), 1);
1522        assert_eq!(spans[0].start, 0);
1523        assert_eq!(spans[0].end, 80);
1524    }
1525
1526    #[test]
1527    fn tracker_new_zero_dimensions() {
1528        let t = DirtyTracker::new(0, 0);
1529        assert_eq!(t.row_count(), 0);
1530        assert!(!t.is_dirty());
1531        // Out-of-bounds operations are no-ops.
1532        let mut t = t;
1533        t.mark_cell(0, 0);
1534        assert!(!t.is_dirty());
1535    }
1536}