Skip to main content

ftui_render/
presenter.rs

1#![forbid(unsafe_code)]
2
3//! Presenter: state-tracked ANSI emission.
4//!
5//! The Presenter transforms buffer diffs into minimal terminal output by tracking
6//! the current terminal state and only emitting sequences when changes are needed.
7//!
8//! # Design Principles
9//!
10//! - **State tracking**: Track current style, link, and cursor to avoid redundant output
11//! - **Run grouping**: Use ChangeRuns to minimize cursor positioning
12//! - **Single write**: Buffer all output and flush once per frame
13//! - **Synchronized output**: Use DEC 2026 to prevent flicker on supported terminals
14//!
15//! # Usage
16//!
17//! ```ignore
18//! use ftui_render::presenter::Presenter;
19//! use ftui_render::buffer::Buffer;
20//! use ftui_render::diff::BufferDiff;
21//! use ftui_core::terminal_capabilities::TerminalCapabilities;
22//!
23//! let caps = TerminalCapabilities::detect();
24//! let mut presenter = Presenter::new(std::io::stdout(), caps);
25//!
26//! let mut current = Buffer::new(80, 24);
27//! let mut next = Buffer::new(80, 24);
28//! // ... render widgets into `next` ...
29//!
30//! let diff = BufferDiff::compute(&current, &next);
31//! presenter.present(&next, &diff)?;
32//! std::mem::swap(&mut current, &mut next);
33//! ```
34
35use std::io::{self, BufWriter, Write};
36
37use crate::ansi::{self, EraseLineMode};
38use crate::buffer::Buffer;
39use crate::cell::{Cell, CellAttrs, PackedRgba, StyleFlags};
40use crate::counting_writer::{CountingWriter, PresentStats, StatsCollector};
41use crate::diff::{BufferDiff, ChangeRun};
42use crate::grapheme_pool::GraphemePool;
43use crate::link_registry::LinkRegistry;
44
45pub use ftui_core::terminal_capabilities::TerminalCapabilities;
46
47/// Size of the internal write buffer (64KB).
48const BUFFER_CAPACITY: usize = 64 * 1024;
49
50// =============================================================================
51// DP Cost Model for ANSI Emission
52// =============================================================================
53
54/// Byte-cost estimates for ANSI cursor and output operations.
55///
56/// The cost model computes the cheapest emission plan for each row by comparing
57/// sparse-run emission (CUP per run) against merged write-through (one CUP,
58/// fill gaps with buffer content). This is a shortest-path problem on a small
59/// state graph per row.
60mod cost_model {
61    use super::ChangeRun;
62
63    /// Number of decimal digits needed to represent `n`.
64    #[inline]
65    fn digit_count(n: u16) -> usize {
66        if n >= 10000 {
67            5
68        } else if n >= 1000 {
69            4
70        } else if n >= 100 {
71            3
72        } else if n >= 10 {
73            2
74        } else {
75            1
76        }
77    }
78
79    /// Byte cost of CUP: `\x1b[{row+1};{col+1}H`
80    #[inline]
81    pub fn cup_cost(row: u16, col: u16) -> usize {
82        // CSI (2) + row digits + ';' (1) + col digits + 'H' (1)
83        4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
84    }
85
86    /// Byte cost of CHA (column-only): `\x1b[{col+1}G`
87    #[inline]
88    pub fn cha_cost(col: u16) -> usize {
89        // CSI (2) + col digits + 'G' (1)
90        3 + digit_count(col.saturating_add(1))
91    }
92
93    /// Byte cost of CUF (cursor forward): `\x1b[{n}C` or `\x1b[C` for n=1.
94    #[inline]
95    pub fn cuf_cost(n: u16) -> usize {
96        match n {
97            0 => 0,
98            1 => 3, // \x1b[C
99            _ => 3 + digit_count(n),
100        }
101    }
102
103    /// Cheapest cursor movement cost from (from_x, from_y) to (to_x, to_y).
104    /// Returns 0 if already at the target position.
105    pub fn cheapest_move_cost(
106        from_x: Option<u16>,
107        from_y: Option<u16>,
108        to_x: u16,
109        to_y: u16,
110    ) -> usize {
111        // Already at target?
112        if from_x == Some(to_x) && from_y == Some(to_y) {
113            return 0;
114        }
115
116        let cup = cup_cost(to_y, to_x);
117
118        match (from_x, from_y) {
119            (Some(fx), Some(fy)) if fy == to_y => {
120                // Same row: compare CHA, CUF, and CUP
121                let cha = cha_cost(to_x);
122                if to_x > fx {
123                    let cuf = cuf_cost(to_x - fx);
124                    cup.min(cha).min(cuf)
125                } else if to_x == fx {
126                    0
127                } else {
128                    // Moving backward: CHA or CUP
129                    cup.min(cha)
130                }
131            }
132            _ => cup,
133        }
134    }
135
136    /// Planned contiguous span to emit on a single row.
137    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138    pub struct RowSpan {
139        /// Row index.
140        pub y: u16,
141        /// Start column (inclusive).
142        pub x0: u16,
143        /// End column (inclusive).
144        pub x1: u16,
145    }
146
147    /// Row emission plan (possibly multiple merged spans).
148    #[derive(Debug, Clone, PartialEq, Eq)]
149    pub struct RowPlan {
150        spans: Vec<RowSpan>,
151        total_cost: usize,
152    }
153
154    impl RowPlan {
155        #[inline]
156        pub fn spans(&self) -> &[RowSpan] {
157            &self.spans
158        }
159
160        /// Total cost of this row plan (for strategy selection).
161        #[inline]
162        #[allow(dead_code)] // API for future diff strategy integration
163        pub fn total_cost(&self) -> usize {
164            self.total_cost
165        }
166    }
167
168    /// Compute the optimal emission plan for a set of runs on the same row.
169    ///
170    /// This is a shortest-path / DP partitioning problem over contiguous run
171    /// segments. Each segment may be emitted as a merged span (writing through
172    /// gaps). Single-run segments correspond to sparse emission.
173    ///
174    /// Gap cells cost ~1 byte each (character content), plus potential style
175    /// overhead estimated at 1 byte per gap cell (conservative).
176    pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
177        debug_assert!(!row_runs.is_empty());
178
179        let row_y = row_runs[0].y;
180        let run_count = row_runs.len();
181
182        // Prefix sum of changed cell counts for O(1) segment cost.
183        let mut prefix_cells = vec![0usize; run_count + 1];
184        for (i, run) in row_runs.iter().enumerate() {
185            prefix_cells[i + 1] = prefix_cells[i] + run.len() as usize;
186        }
187
188        // DP over segments: dp[j] is min cost to emit runs[0..=j].
189        let mut dp = vec![usize::MAX; run_count];
190        let mut prev = vec![0usize; run_count];
191
192        for j in 0..run_count {
193            let mut best_cost = usize::MAX;
194            let mut best_i = j;
195
196            // Optimization: iterate backwards and break if the gap becomes too large.
197            // The gap cost grows linearly, while cursor movement cost is bounded (~10-15 bytes).
198            // Once the gap exceeds ~20 cells, merging is strictly worse than moving.
199            // We use 32 as a conservative safety bound.
200            for i in (0..=j).rev() {
201                let changed_cells = prefix_cells[j + 1] - prefix_cells[i];
202                let total_cells = (row_runs[j].x1 - row_runs[i].x0 + 1) as usize;
203                let gap_cells = total_cells - changed_cells;
204
205                if gap_cells > 32 {
206                    break;
207                }
208
209                let from_x = if i == 0 {
210                    prev_x
211                } else {
212                    Some(row_runs[i - 1].x1.saturating_add(1))
213                };
214                let from_y = if i == 0 { prev_y } else { Some(row_y) };
215
216                let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
217                let gap_overhead = gap_cells * 2; // conservative: char + style amortized
218                let emit_cost = changed_cells + gap_overhead;
219
220                let prev_cost = if i == 0 { 0 } else { dp[i - 1] };
221                let cost = prev_cost
222                    .saturating_add(move_cost)
223                    .saturating_add(emit_cost);
224
225                if cost < best_cost {
226                    best_cost = cost;
227                    best_i = i;
228                }
229            }
230
231            dp[j] = best_cost;
232            prev[j] = best_i;
233        }
234
235        // Reconstruct spans from back to front.
236        let mut spans = Vec::new();
237        let mut j = run_count - 1;
238        loop {
239            let i = prev[j];
240            spans.push(RowSpan {
241                y: row_y,
242                x0: row_runs[i].x0,
243                x1: row_runs[j].x1,
244            });
245            if i == 0 {
246                break;
247            }
248            j = i - 1;
249        }
250        spans.reverse();
251
252        RowPlan {
253            spans,
254            total_cost: dp[run_count - 1],
255        }
256    }
257}
258
259/// Cached style state for comparison.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261struct CellStyle {
262    fg: PackedRgba,
263    bg: PackedRgba,
264    attrs: StyleFlags,
265}
266
267impl Default for CellStyle {
268    fn default() -> Self {
269        Self {
270            fg: PackedRgba::TRANSPARENT,
271            bg: PackedRgba::TRANSPARENT,
272            attrs: StyleFlags::empty(),
273        }
274    }
275}
276impl CellStyle {
277    fn from_cell(cell: &Cell) -> Self {
278        Self {
279            fg: cell.fg,
280            bg: cell.bg,
281            attrs: cell.attrs.flags(),
282        }
283    }
284}
285
286/// State-tracked ANSI presenter.
287///
288/// Transforms buffer diffs into minimal terminal output by tracking
289/// the current terminal state and only emitting necessary escape sequences.
290pub struct Presenter<W: Write> {
291    /// Buffered writer for efficient output, with byte counting.
292    writer: CountingWriter<BufWriter<W>>,
293    /// Current style state (None = unknown/reset).
294    current_style: Option<CellStyle>,
295    /// Current hyperlink ID (None = no link).
296    current_link: Option<u32>,
297    /// Current cursor X position (0-indexed). None = unknown.
298    cursor_x: Option<u16>,
299    /// Current cursor Y position (0-indexed). None = unknown.
300    cursor_y: Option<u16>,
301    /// Terminal capabilities for conditional output.
302    capabilities: TerminalCapabilities,
303}
304
305impl<W: Write> Presenter<W> {
306    /// Create a new presenter with the given writer and capabilities.
307    pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
308        Self {
309            writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
310            current_style: None,
311            current_link: None,
312            cursor_x: None,
313            cursor_y: None,
314            capabilities,
315        }
316    }
317
318    /// Get the terminal capabilities.
319    #[inline]
320    pub fn capabilities(&self) -> &TerminalCapabilities {
321        &self.capabilities
322    }
323
324    /// Present a frame using the given buffer and diff.
325    ///
326    /// This is the main entry point for rendering. It:
327    /// 1. Begins synchronized output (if supported)
328    /// 2. Emits changes based on the diff
329    /// 3. Resets style and closes links
330    /// 4. Ends synchronized output
331    /// 5. Flushes all buffered output
332    pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
333        self.present_with_pool(buffer, diff, None, None)
334    }
335
336    /// Present a frame with grapheme pool and link registry.
337    pub fn present_with_pool(
338        &mut self,
339        buffer: &Buffer,
340        diff: &BufferDiff,
341        pool: Option<&GraphemePool>,
342        links: Option<&LinkRegistry>,
343    ) -> io::Result<PresentStats> {
344        #[cfg(feature = "tracing")]
345        let _span = tracing::info_span!(
346            "present",
347            width = buffer.width(),
348            height = buffer.height(),
349            changes = diff.len()
350        );
351        #[cfg(feature = "tracing")]
352        let _guard = _span.enter();
353
354        // Calculate runs upfront for stats
355        let runs = diff.runs();
356        let run_count = runs.len();
357        let cells_changed = diff.len();
358
359        // Start stats collection
360        self.writer.reset_counter();
361        let collector = StatsCollector::start(cells_changed, run_count);
362
363        // Begin synchronized output to prevent flicker
364        if self.capabilities.sync_output {
365            ansi::sync_begin(&mut self.writer)?;
366        }
367
368        // Emit diff using run grouping for efficiency
369        self.emit_runs(buffer, &runs, pool, links)?;
370
371        // Reset style at end (clean state for next frame)
372        ansi::sgr_reset(&mut self.writer)?;
373        self.current_style = None;
374
375        // Close any open hyperlink
376        if self.current_link.is_some() {
377            ansi::hyperlink_end(&mut self.writer)?;
378            self.current_link = None;
379        }
380
381        // End synchronized output
382        if self.capabilities.sync_output {
383            ansi::sync_end(&mut self.writer)?;
384        }
385
386        self.writer.flush()?;
387
388        let stats = collector.finish(self.writer.bytes_written());
389
390        #[cfg(feature = "tracing")]
391        {
392            stats.log();
393            tracing::trace!("frame presented");
394        }
395
396        Ok(stats)
397    }
398
399    /// Emit runs of changed cells using the DP cost model.
400    ///
401    /// Groups runs by row, then for each row decides whether to emit runs
402    /// individually (sparse) or merge them (write through gaps) based on
403    /// byte cost estimation.
404    fn emit_runs(
405        &mut self,
406        buffer: &Buffer,
407        runs: &[ChangeRun],
408        pool: Option<&GraphemePool>,
409        links: Option<&LinkRegistry>,
410    ) -> io::Result<()> {
411        #[cfg(feature = "tracing")]
412        let _span = tracing::debug_span!("emit_diff");
413        #[cfg(feature = "tracing")]
414        let _guard = _span.enter();
415
416        #[cfg(feature = "tracing")]
417        tracing::trace!(run_count = runs.len(), "emitting runs");
418
419        // Group runs by row and apply cost model per row
420        let mut i = 0;
421        while i < runs.len() {
422            let row_y = runs[i].y;
423
424            // Collect all runs on this row
425            let row_start = i;
426            while i < runs.len() && runs[i].y == row_y {
427                i += 1;
428            }
429            let row_runs = &runs[row_start..i];
430
431            let plan = cost_model::plan_row(row_runs, self.cursor_x, self.cursor_y);
432
433            #[cfg(feature = "tracing")]
434            tracing::trace!(
435                row = row_y,
436                spans = plan.spans().len(),
437                cost = plan.total_cost(),
438                "row plan"
439            );
440
441            for span in plan.spans() {
442                self.move_cursor_optimal(span.x0, span.y)?;
443                for x in span.x0..=span.x1 {
444                    let cell = buffer.get_unchecked(x, span.y);
445                    self.emit_cell(x, cell, pool, links)?;
446                }
447            }
448        }
449        Ok(())
450    }
451
452    /// Emit a single cell.
453    fn emit_cell(
454        &mut self,
455        x: u16,
456        cell: &Cell,
457        pool: Option<&GraphemePool>,
458        links: Option<&LinkRegistry>,
459    ) -> io::Result<()> {
460        // Skip continuation cells (second cell of wide characters).
461        // The wide character already advanced the cursor by its full width.
462        //
463        // EXCEPTION: Orphan continuations (not covered by a preceding wide char)
464        // must be treated as empty cells to ensure old content is cleared.
465        // If cursor_x <= x, it means the cursor hasn't been advanced past this
466        // position by a previous wide char emission, so this is an orphan.
467        let is_orphan = cell.is_continuation() && self.cursor_x.is_some_and(|cx| cx <= x);
468
469        if cell.is_continuation() && !is_orphan {
470            return Ok(());
471        }
472
473        // Treat orphan as empty default cell
474        let effective_cell = if is_orphan { &Cell::default() } else { cell };
475
476        // Emit style changes if needed
477        self.emit_style_changes(effective_cell)?;
478
479        // Emit link changes if needed
480        self.emit_link_changes(effective_cell, links)?;
481
482        // Calculate effective width and check for zero-width content (e.g. combining marks)
483        // stored as standalone cells. These must be replaced to maintain grid alignment.
484        let raw_width = effective_cell.content.width();
485        let is_zero_width_content =
486            raw_width == 0 && !effective_cell.is_empty() && !effective_cell.is_continuation();
487
488        if is_zero_width_content {
489            // Replace with U+FFFD Replacement Character (width 1)
490            self.writer.write_all(b"\xEF\xBF\xBD")?;
491        } else {
492            // Emit normal content
493            self.emit_content(effective_cell, pool)?;
494        }
495
496        // Update cursor position (character output advances cursor)
497        if let Some(cx) = self.cursor_x {
498            // Empty cells are emitted as spaces (width 1).
499            // Zero-width content replaced by U+FFFD is width 1.
500            let width = if effective_cell.is_empty() || is_zero_width_content {
501                1
502            } else {
503                raw_width
504            };
505            self.cursor_x = Some(cx.saturating_add(width as u16));
506        }
507
508        Ok(())
509    }
510
511    /// Emit style changes if the cell style differs from current.
512    ///
513    /// Uses SGR delta: instead of resetting and re-applying all style properties,
514    /// we compute the minimal set of changes needed (fg delta, bg delta, attr
515    /// toggles). Falls back to reset+apply only when a full reset would be cheaper.
516    fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
517        let new_style = CellStyle::from_cell(cell);
518
519        // Check if style changed
520        if self.current_style == Some(new_style) {
521            return Ok(());
522        }
523
524        match self.current_style {
525            None => {
526                // No known state - must do full apply (but skip reset if we haven't
527                // emitted anything yet, the frame-start reset handles that).
528                self.emit_style_full(new_style)?;
529            }
530            Some(old_style) => {
531                self.emit_style_delta(old_style, new_style)?;
532            }
533        }
534
535        self.current_style = Some(new_style);
536        Ok(())
537    }
538
539    /// Full style apply (reset + set all properties). Used when previous state is unknown.
540    fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
541        ansi::sgr_reset(&mut self.writer)?;
542        if style.fg.a() > 0 {
543            ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
544        }
545        if style.bg.a() > 0 {
546            ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
547        }
548        if !style.attrs.is_empty() {
549            ansi::sgr_flags(&mut self.writer, style.attrs)?;
550        }
551        Ok(())
552    }
553
554    #[inline]
555    fn dec_len_u8(value: u8) -> u32 {
556        if value >= 100 {
557            3
558        } else if value >= 10 {
559            2
560        } else {
561            1
562        }
563    }
564
565    #[inline]
566    fn sgr_code_len(code: u8) -> u32 {
567        2 + Self::dec_len_u8(code) + 1
568    }
569
570    #[inline]
571    fn sgr_flags_len(flags: StyleFlags) -> u32 {
572        if flags.is_empty() {
573            return 0;
574        }
575        let mut count = 0u32;
576        let mut digits = 0u32;
577        for (flag, codes) in ansi::FLAG_TABLE {
578            if flags.contains(flag) {
579                count += 1;
580                digits += Self::dec_len_u8(codes.on);
581            }
582        }
583        if count == 0 {
584            return 0;
585        }
586        3 + digits + (count - 1)
587    }
588
589    #[inline]
590    fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
591        if flags.is_empty() {
592            return 0;
593        }
594        let mut len = 0u32;
595        for (flag, codes) in ansi::FLAG_TABLE {
596            if flags.contains(flag) {
597                len += Self::sgr_code_len(codes.off);
598            }
599        }
600        len
601    }
602
603    #[inline]
604    fn sgr_rgb_len(color: PackedRgba) -> u32 {
605        10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
606    }
607
608    /// Emit minimal SGR delta between old and new styles.
609    ///
610    /// Computes which properties changed and emits only those.
611    /// Falls back to reset+apply when that would produce fewer bytes.
612    fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
613        let attrs_removed = old.attrs & !new.attrs;
614        let attrs_added = new.attrs & !old.attrs;
615        let fg_changed = old.fg != new.fg;
616        let bg_changed = old.bg != new.bg;
617
618        let mut collateral = StyleFlags::empty();
619        if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
620            collateral |= StyleFlags::DIM;
621        }
622        if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
623            collateral |= StyleFlags::BOLD;
624        }
625
626        let mut delta_len = 0u32;
627        delta_len += Self::sgr_flags_off_len(attrs_removed);
628        delta_len += Self::sgr_flags_len(collateral);
629        delta_len += Self::sgr_flags_len(attrs_added);
630        if fg_changed {
631            delta_len += if new.fg.a() == 0 {
632                5
633            } else {
634                Self::sgr_rgb_len(new.fg)
635            };
636        }
637        if bg_changed {
638            delta_len += if new.bg.a() == 0 {
639                5
640            } else {
641                Self::sgr_rgb_len(new.bg)
642            };
643        }
644
645        let mut baseline_len = 4u32;
646        if new.fg.a() > 0 {
647            baseline_len += Self::sgr_rgb_len(new.fg);
648        }
649        if new.bg.a() > 0 {
650            baseline_len += Self::sgr_rgb_len(new.bg);
651        }
652        baseline_len += Self::sgr_flags_len(new.attrs);
653
654        if delta_len > baseline_len {
655            return self.emit_style_full(new);
656        }
657
658        // Handle attr removal: emit individual off codes
659        if !attrs_removed.is_empty() {
660            let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
661            // Re-enable any collaterally disabled flags
662            if !collateral.is_empty() {
663                ansi::sgr_flags(&mut self.writer, collateral)?;
664            }
665        }
666
667        // Handle attr addition: emit on codes for newly added flags
668        if !attrs_added.is_empty() {
669            ansi::sgr_flags(&mut self.writer, attrs_added)?;
670        }
671
672        // Handle fg color change
673        if fg_changed {
674            ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
675        }
676
677        // Handle bg color change
678        if bg_changed {
679            ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
680        }
681
682        Ok(())
683    }
684
685    /// Emit hyperlink changes if the cell link differs from current.
686    fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
687        let raw_link_id = cell.attrs.link_id();
688        let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
689            None
690        } else {
691            Some(raw_link_id)
692        };
693
694        // Check if link changed
695        if self.current_link == new_link {
696            return Ok(());
697        }
698
699        // Close current link if open
700        if self.current_link.is_some() {
701            ansi::hyperlink_end(&mut self.writer)?;
702        }
703
704        // Open new link if present and resolvable
705        let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
706            && let Some(url) = registry.get(link_id)
707        {
708            ansi::hyperlink_start(&mut self.writer, url)?;
709            true
710        } else {
711            false
712        };
713
714        // Only track as current if we actually opened it
715        self.current_link = if actually_opened { new_link } else { None };
716        Ok(())
717    }
718
719    /// Emit cell content (character or grapheme).
720    fn emit_content(&mut self, cell: &Cell, pool: Option<&GraphemePool>) -> io::Result<()> {
721        // Check if this is a grapheme reference
722        if let Some(grapheme_id) = cell.content.grapheme_id() {
723            if let Some(pool) = pool
724                && let Some(text) = pool.get(grapheme_id)
725            {
726                return self.writer.write_all(text.as_bytes());
727            }
728            // Fallback: emit replacement characters matching expected width
729            // to maintain cursor synchronization.
730            let width = cell.content.width();
731            if width > 0 {
732                for _ in 0..width {
733                    self.writer.write_all(b"?")?;
734                }
735            }
736            return Ok(());
737        }
738
739        // Regular character content
740        if let Some(ch) = cell.content.as_char() {
741            // Sanitize control characters that would break the grid.
742            let safe_ch = if ch.is_control() { ' ' } else { ch };
743            let mut buf = [0u8; 4];
744            let encoded = safe_ch.encode_utf8(&mut buf);
745            self.writer.write_all(encoded.as_bytes())
746        } else {
747            // Empty cell - emit space
748            self.writer.write_all(b" ")
749        }
750    }
751
752    /// Move cursor to the specified position.
753    fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
754        // Skip if already at position
755        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
756            return Ok(());
757        }
758
759        // Use CUP (cursor position) for absolute positioning
760        ansi::cup(&mut self.writer, y, x)?;
761        self.cursor_x = Some(x);
762        self.cursor_y = Some(y);
763        Ok(())
764    }
765
766    /// Move cursor using the cheapest available operation.
767    ///
768    /// Compares CUP (absolute), CHA (column-only), and CUF (relative forward)
769    /// to select the minimum-cost cursor movement.
770    fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
771        // Skip if already at position
772        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
773            return Ok(());
774        }
775
776        // Decide cheapest move
777        let same_row = self.cursor_y == Some(y);
778        let forward = same_row && self.cursor_x.is_some_and(|cx| x > cx);
779
780        if same_row && forward {
781            let dx = x - self.cursor_x.unwrap();
782            let cuf = cost_model::cuf_cost(dx);
783            let cha = cost_model::cha_cost(x);
784            let cup = cost_model::cup_cost(y, x);
785
786            if cuf <= cha && cuf <= cup {
787                ansi::cuf(&mut self.writer, dx)?;
788            } else if cha <= cup {
789                ansi::cha(&mut self.writer, x)?;
790            } else {
791                ansi::cup(&mut self.writer, y, x)?;
792            }
793        } else if same_row {
794            // Same row, backward or same column
795            let cha = cost_model::cha_cost(x);
796            let cup = cost_model::cup_cost(y, x);
797            if cha <= cup {
798                ansi::cha(&mut self.writer, x)?;
799            } else {
800                ansi::cup(&mut self.writer, y, x)?;
801            }
802        } else {
803            // Different row: CUP is the only option
804            ansi::cup(&mut self.writer, y, x)?;
805        }
806
807        self.cursor_x = Some(x);
808        self.cursor_y = Some(y);
809        Ok(())
810    }
811
812    /// Clear the entire screen.
813    pub fn clear_screen(&mut self) -> io::Result<()> {
814        ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
815        ansi::cup(&mut self.writer, 0, 0)?;
816        self.cursor_x = Some(0);
817        self.cursor_y = Some(0);
818        self.writer.flush()
819    }
820
821    /// Clear a single line.
822    pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
823        self.move_cursor_to(0, y)?;
824        ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
825        self.writer.flush()
826    }
827
828    /// Hide the cursor.
829    pub fn hide_cursor(&mut self) -> io::Result<()> {
830        ansi::cursor_hide(&mut self.writer)?;
831        self.writer.flush()
832    }
833
834    /// Show the cursor.
835    pub fn show_cursor(&mut self) -> io::Result<()> {
836        ansi::cursor_show(&mut self.writer)?;
837        self.writer.flush()
838    }
839
840    /// Position the cursor at the specified coordinates.
841    pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
842        self.move_cursor_to(x, y)?;
843        self.writer.flush()
844    }
845
846    /// Reset the presenter state.
847    ///
848    /// Useful after resize or when terminal state is unknown.
849    pub fn reset(&mut self) {
850        self.current_style = None;
851        self.current_link = None;
852        self.cursor_x = None;
853        self.cursor_y = None;
854    }
855
856    /// Flush any buffered output.
857    pub fn flush(&mut self) -> io::Result<()> {
858        self.writer.flush()
859    }
860
861    /// Get the inner writer (consuming the presenter).
862    ///
863    /// Flushes any buffered data before returning the writer.
864    pub fn into_inner(self) -> Result<W, io::Error> {
865        self.writer
866            .into_inner() // CountingWriter -> BufWriter<W>
867            .into_inner() // BufWriter<W> -> Result<W, IntoInnerError>
868            .map_err(|e| e.into_error())
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use crate::cell::CellAttrs;
876    use crate::link_registry::LinkRegistry;
877
878    fn test_presenter() -> Presenter<Vec<u8>> {
879        let caps = TerminalCapabilities::basic();
880        Presenter::new(Vec::new(), caps)
881    }
882
883    fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
884        let mut caps = TerminalCapabilities::basic();
885        caps.sync_output = true;
886        Presenter::new(Vec::new(), caps)
887    }
888
889    fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
890        presenter.into_inner().unwrap()
891    }
892
893    fn legacy_plan_row(
894        row_runs: &[ChangeRun],
895        prev_x: Option<u16>,
896        prev_y: Option<u16>,
897    ) -> Vec<cost_model::RowSpan> {
898        if row_runs.is_empty() {
899            return Vec::new();
900        }
901
902        if row_runs.len() == 1 {
903            let run = row_runs[0];
904            return vec![cost_model::RowSpan {
905                y: run.y,
906                x0: run.x0,
907                x1: run.x1,
908            }];
909        }
910
911        let row_y = row_runs[0].y;
912        let first_x = row_runs[0].x0;
913        let last_x = row_runs[row_runs.len() - 1].x1;
914
915        // Estimate sparse cost: sum of move + content for each run
916        let mut sparse_cost: usize = 0;
917        let mut cursor_x = prev_x;
918        let mut cursor_y = prev_y;
919
920        for run in row_runs {
921            let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
922            let cells = (run.x1 - run.x0 + 1) as usize;
923            sparse_cost += move_cost + cells;
924            cursor_x = Some(run.x1.saturating_add(1));
925            cursor_y = Some(row_y);
926        }
927
928        // Estimate merged cost: one move + all cells from first to last
929        let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
930        let total_cells = (last_x - first_x + 1) as usize;
931        let changed_cells: usize = row_runs.iter().map(|r| (r.x1 - r.x0 + 1) as usize).sum();
932        let gap_cells = total_cells - changed_cells;
933        let gap_overhead = gap_cells * 2;
934        let merged_cost = merge_move + changed_cells + gap_overhead;
935
936        if merged_cost < sparse_cost {
937            vec![cost_model::RowSpan {
938                y: row_y,
939                x0: first_x,
940                x1: last_x,
941            }]
942        } else {
943            row_runs
944                .iter()
945                .map(|run| cost_model::RowSpan {
946                    y: run.y,
947                    x0: run.x0,
948                    x1: run.x1,
949                })
950                .collect()
951        }
952    }
953
954    fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
955        let mut presenter = test_presenter();
956
957        for span in spans {
958            presenter
959                .move_cursor_optimal(span.x0, span.y)
960                .expect("cursor move should succeed");
961            for x in span.x0..=span.x1 {
962                let cell = buffer.get_unchecked(x, span.y);
963                presenter
964                    .emit_cell(x, cell, None, None)
965                    .expect("emit_cell should succeed");
966            }
967        }
968
969        presenter
970            .writer
971            .write_all(b"\x1b[0m")
972            .expect("reset should succeed");
973
974        presenter.into_inner().expect("presenter output")
975    }
976
977    #[test]
978    fn empty_diff_produces_minimal_output() {
979        let mut presenter = test_presenter();
980        let buffer = Buffer::new(10, 10);
981        let diff = BufferDiff::new();
982
983        presenter.present(&buffer, &diff).unwrap();
984        let output = get_output(presenter);
985
986        // Should only have SGR reset
987        assert!(output.starts_with(b"\x1b[0m"));
988    }
989
990    #[test]
991    fn sync_output_wraps_frame() {
992        let mut presenter = test_presenter_with_sync();
993        let mut buffer = Buffer::new(3, 1);
994        buffer.set_raw(0, 0, Cell::from_char('X'));
995
996        let old = Buffer::new(3, 1);
997        let diff = BufferDiff::compute(&old, &buffer);
998
999        presenter.present(&buffer, &diff).unwrap();
1000        let output = get_output(presenter);
1001
1002        assert!(
1003            output.starts_with(ansi::SYNC_BEGIN),
1004            "sync output should begin with DEC 2026 begin"
1005        );
1006        assert!(
1007            output.ends_with(ansi::SYNC_END),
1008            "sync output should end with DEC 2026 end"
1009        );
1010    }
1011
1012    #[test]
1013    fn hyperlink_sequences_emitted_and_closed() {
1014        let mut presenter = test_presenter();
1015        let mut buffer = Buffer::new(3, 1);
1016
1017        let mut registry = LinkRegistry::new();
1018        let link_id = registry.register("https://example.com");
1019        let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1020        buffer.set_raw(0, 0, linked);
1021
1022        let old = Buffer::new(3, 1);
1023        let diff = BufferDiff::compute(&old, &buffer);
1024
1025        presenter
1026            .present_with_pool(&buffer, &diff, None, Some(&registry))
1027            .unwrap();
1028        let output = get_output(presenter);
1029
1030        let start = b"\x1b]8;;https://example.com\x1b\\";
1031        let end = b"\x1b]8;;\x1b\\";
1032
1033        let start_pos = output
1034            .windows(start.len())
1035            .position(|w| w == start)
1036            .expect("hyperlink start not found");
1037        let end_pos = output
1038            .windows(end.len())
1039            .position(|w| w == end)
1040            .expect("hyperlink end not found");
1041        let char_pos = output
1042            .iter()
1043            .position(|&b| b == b'L')
1044            .expect("linked character not found");
1045
1046        assert!(start_pos < char_pos, "link start should precede text");
1047        assert!(char_pos < end_pos, "link end should follow text");
1048    }
1049
1050    #[test]
1051    fn single_cell_change() {
1052        let mut presenter = test_presenter();
1053        let mut buffer = Buffer::new(10, 10);
1054        buffer.set_raw(5, 5, Cell::from_char('X'));
1055
1056        let old = Buffer::new(10, 10);
1057        let diff = BufferDiff::compute(&old, &buffer);
1058
1059        presenter.present(&buffer, &diff).unwrap();
1060        let output = get_output(presenter);
1061
1062        // Should contain cursor position and character
1063        let output_str = String::from_utf8_lossy(&output);
1064        assert!(output_str.contains("X"));
1065        assert!(output_str.contains("\x1b[")); // Contains escape sequences
1066    }
1067
1068    #[test]
1069    fn style_tracking_avoids_redundant_sgr() {
1070        let mut presenter = test_presenter();
1071        let mut buffer = Buffer::new(10, 1);
1072
1073        // Set multiple cells with same style
1074        let fg = PackedRgba::rgb(255, 0, 0);
1075        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1076        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1077        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1078
1079        let old = Buffer::new(10, 1);
1080        let diff = BufferDiff::compute(&old, &buffer);
1081
1082        presenter.present(&buffer, &diff).unwrap();
1083        let output = get_output(presenter);
1084
1085        // Count SGR sequences (should be minimal due to style tracking)
1086        let output_str = String::from_utf8_lossy(&output);
1087        let sgr_count = output_str.matches("\x1b[38;2").count();
1088        // Should have exactly 1 fg color sequence (style set once, reused for ABC)
1089        assert_eq!(
1090            sgr_count, 1,
1091            "Expected 1 SGR fg sequence, got {}",
1092            sgr_count
1093        );
1094    }
1095
1096    #[test]
1097    fn reset_reapplies_style_after_clear() {
1098        let mut presenter = test_presenter();
1099        let mut buffer = Buffer::new(1, 1);
1100        let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1101        buffer.set_raw(0, 0, styled);
1102
1103        let old = Buffer::new(1, 1);
1104        let diff = BufferDiff::compute(&old, &buffer);
1105
1106        presenter.present(&buffer, &diff).unwrap();
1107        presenter.reset();
1108        presenter.present(&buffer, &diff).unwrap();
1109
1110        let output = get_output(presenter);
1111        let output_str = String::from_utf8_lossy(&output);
1112        let sgr_count = output_str.matches("\x1b[38;2").count();
1113
1114        assert_eq!(
1115            sgr_count, 2,
1116            "Expected style to be re-applied after reset, got {sgr_count} sequences"
1117        );
1118    }
1119
1120    #[test]
1121    fn cursor_position_optimized() {
1122        let mut presenter = test_presenter();
1123        let mut buffer = Buffer::new(10, 5);
1124
1125        // Set adjacent cells (should be one run)
1126        buffer.set_raw(3, 2, Cell::from_char('A'));
1127        buffer.set_raw(4, 2, Cell::from_char('B'));
1128        buffer.set_raw(5, 2, Cell::from_char('C'));
1129
1130        let old = Buffer::new(10, 5);
1131        let diff = BufferDiff::compute(&old, &buffer);
1132
1133        presenter.present(&buffer, &diff).unwrap();
1134        let output = get_output(presenter);
1135
1136        // Should have only one CUP sequence for the run
1137        let output_str = String::from_utf8_lossy(&output);
1138        let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1139
1140        // Content should be "ABC" somewhere in output
1141        assert!(
1142            output_str.contains("ABC")
1143                || (output_str.contains('A')
1144                    && output_str.contains('B')
1145                    && output_str.contains('C'))
1146        );
1147    }
1148
1149    #[test]
1150    fn sync_output_wrapped_when_supported() {
1151        let mut presenter = test_presenter_with_sync();
1152        let buffer = Buffer::new(10, 10);
1153        let diff = BufferDiff::new();
1154
1155        presenter.present(&buffer, &diff).unwrap();
1156        let output = get_output(presenter);
1157
1158        // Should have sync begin and end
1159        assert!(output.starts_with(ansi::SYNC_BEGIN));
1160        assert!(
1161            output
1162                .windows(ansi::SYNC_END.len())
1163                .any(|w| w == ansi::SYNC_END)
1164        );
1165    }
1166
1167    #[test]
1168    fn clear_screen_works() {
1169        let mut presenter = test_presenter();
1170        presenter.clear_screen().unwrap();
1171        let output = get_output(presenter);
1172
1173        // Should contain erase display sequence
1174        assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1175    }
1176
1177    #[test]
1178    fn cursor_visibility() {
1179        let mut presenter = test_presenter();
1180
1181        presenter.hide_cursor().unwrap();
1182        presenter.show_cursor().unwrap();
1183
1184        let output = get_output(presenter);
1185        let output_str = String::from_utf8_lossy(&output);
1186
1187        assert!(output_str.contains("\x1b[?25l")); // Hide
1188        assert!(output_str.contains("\x1b[?25h")); // Show
1189    }
1190
1191    #[test]
1192    fn reset_clears_state() {
1193        let mut presenter = test_presenter();
1194        presenter.cursor_x = Some(50);
1195        presenter.cursor_y = Some(20);
1196        presenter.current_style = Some(CellStyle::default());
1197
1198        presenter.reset();
1199
1200        assert!(presenter.cursor_x.is_none());
1201        assert!(presenter.cursor_y.is_none());
1202        assert!(presenter.current_style.is_none());
1203    }
1204
1205    #[test]
1206    fn position_cursor() {
1207        let mut presenter = test_presenter();
1208        presenter.position_cursor(10, 5).unwrap();
1209
1210        let output = get_output(presenter);
1211        // CUP is 1-indexed: row 6, col 11
1212        assert!(
1213            output
1214                .windows(b"\x1b[6;11H".len())
1215                .any(|w| w == b"\x1b[6;11H")
1216        );
1217    }
1218
1219    #[test]
1220    fn skip_cursor_move_when_already_at_position() {
1221        let mut presenter = test_presenter();
1222        presenter.cursor_x = Some(5);
1223        presenter.cursor_y = Some(3);
1224
1225        // Move to same position
1226        presenter.move_cursor_to(5, 3).unwrap();
1227
1228        // Should produce no output
1229        let output = get_output(presenter);
1230        assert!(output.is_empty());
1231    }
1232
1233    #[test]
1234    fn continuation_cells_skipped() {
1235        let mut presenter = test_presenter();
1236        let mut buffer = Buffer::new(10, 1);
1237
1238        // Set a wide character
1239        buffer.set_raw(0, 0, Cell::from_char('中'));
1240        // The next cell would be a continuation - simulate it
1241        buffer.set_raw(1, 0, Cell::CONTINUATION);
1242
1243        // Create a diff that includes both cells
1244        let old = Buffer::new(10, 1);
1245        let diff = BufferDiff::compute(&old, &buffer);
1246
1247        presenter.present(&buffer, &diff).unwrap();
1248        let output = get_output(presenter);
1249
1250        // Should contain the wide character
1251        let output_str = String::from_utf8_lossy(&output);
1252        assert!(output_str.contains('中'));
1253    }
1254
1255    #[test]
1256    fn wide_char_missing_continuation_causes_drift() {
1257        let mut presenter = test_presenter();
1258        let mut buffer = Buffer::new(10, 1);
1259
1260        // Bug scenario: User sets wide char but forgets continuation
1261        buffer.set_raw(0, 0, Cell::from_char('中'));
1262        // (1,0) remains empty (space)
1263
1264        let old = Buffer::new(10, 1);
1265        let diff = BufferDiff::compute(&old, &buffer);
1266
1267        presenter.present(&buffer, &diff).unwrap();
1268        let output = get_output(presenter);
1269        let _output_str = String::from_utf8_lossy(&output);
1270
1271        // Expected if broken: '中' (width 2) followed by ' ' (width 1)
1272        // '中' takes x=0,1 on screen. Cursor moves to 2.
1273        // Loop visits x=1 (empty). Emits ' '. Cursor moves to 3.
1274        // So we emitted 3 columns worth of stuff for 2 cells of buffer.
1275
1276        // This is hard to assert on the raw string without parsing ANSI,
1277        // but we know '中' is bytes e4 b8 ad.
1278
1279        // If correct (with continuation):
1280        // x=0: emits '中'. cursor -> 2.
1281        // x=1: skipped (continuation).
1282        // x=2: next char...
1283
1284        // If incorrect (current behavior):
1285        // x=0: emits '中'. cursor -> 2.
1286        // x=1: emits ' '. cursor -> 3.
1287
1288        // We can check if a space is emitted immediately after the wide char.
1289        // Note: Presenter might optimize cursor movement, but here we are writing sequentially.
1290
1291        // The output should contain '中' then ' '.
1292        // In a correct world, x=1 is CONTINUATION, so ' ' is NOT emitted for x=1.
1293
1294        // So if we see '中' followed immediately by ' ' (or escape sequence then ' '), it implies drift IF x=1 was supposed to be covered by '中'.
1295
1296        // To verify this failure, we assert that the output DOES contain the space.
1297        // If we fix the bug in Buffer::set, this test setup would need to use set() instead of set_raw()
1298        // to prove the fix.
1299
1300        // But for now, let's just assert the current broken behavior exists?
1301        // No, I want to assert the *bug* is that the buffer allows this state.
1302        // The Presenter is doing its job (GIGO).
1303
1304        // Let's rely on the fix verification instead.
1305    }
1306
1307    #[test]
1308    fn hyperlink_emitted_with_registry() {
1309        let mut presenter = test_presenter();
1310        let mut buffer = Buffer::new(10, 1);
1311        let mut links = LinkRegistry::new();
1312
1313        let link_id = links.register("https://example.com");
1314        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1315        buffer.set_raw(0, 0, cell);
1316
1317        let old = Buffer::new(10, 1);
1318        let diff = BufferDiff::compute(&old, &buffer);
1319
1320        presenter
1321            .present_with_pool(&buffer, &diff, None, Some(&links))
1322            .unwrap();
1323        let output = get_output(presenter);
1324        let output_str = String::from_utf8_lossy(&output);
1325
1326        // OSC 8 open with URL
1327        assert!(
1328            output_str.contains("\x1b]8;;https://example.com\x1b\\"),
1329            "Expected OSC 8 open, got: {:?}",
1330            output_str
1331        );
1332        // OSC 8 close (empty URL)
1333        assert!(
1334            output_str.contains("\x1b]8;;\x1b\\"),
1335            "Expected OSC 8 close, got: {:?}",
1336            output_str
1337        );
1338    }
1339
1340    #[test]
1341    fn hyperlink_not_emitted_without_registry() {
1342        let mut presenter = test_presenter();
1343        let mut buffer = Buffer::new(10, 1);
1344
1345        // Set a link ID without providing a registry
1346        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1347        buffer.set_raw(0, 0, cell);
1348
1349        let old = Buffer::new(10, 1);
1350        let diff = BufferDiff::compute(&old, &buffer);
1351
1352        // Present without link registry
1353        presenter.present(&buffer, &diff).unwrap();
1354        let output = get_output(presenter);
1355        let output_str = String::from_utf8_lossy(&output);
1356
1357        // No OSC 8 sequences should appear
1358        assert!(
1359            !output_str.contains("\x1b]8;"),
1360            "OSC 8 should not appear without registry, got: {:?}",
1361            output_str
1362        );
1363    }
1364
1365    #[test]
1366    fn hyperlink_not_emitted_for_unknown_id() {
1367        let mut presenter = test_presenter();
1368        let mut buffer = Buffer::new(10, 1);
1369        let links = LinkRegistry::new();
1370
1371        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1372        buffer.set_raw(0, 0, cell);
1373
1374        let old = Buffer::new(10, 1);
1375        let diff = BufferDiff::compute(&old, &buffer);
1376
1377        presenter
1378            .present_with_pool(&buffer, &diff, None, Some(&links))
1379            .unwrap();
1380        let output = get_output(presenter);
1381        let output_str = String::from_utf8_lossy(&output);
1382
1383        assert!(
1384            !output_str.contains("\x1b]8;"),
1385            "OSC 8 should not appear for unknown link IDs, got: {:?}",
1386            output_str
1387        );
1388        assert!(output_str.contains('L'));
1389    }
1390
1391    #[test]
1392    fn hyperlink_closed_at_frame_end() {
1393        let mut presenter = test_presenter();
1394        let mut buffer = Buffer::new(10, 1);
1395        let mut links = LinkRegistry::new();
1396
1397        let link_id = links.register("https://example.com");
1398        // Set all cells with the same link
1399        for x in 0..5 {
1400            buffer.set_raw(
1401                x,
1402                0,
1403                Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1404            );
1405        }
1406
1407        let old = Buffer::new(10, 1);
1408        let diff = BufferDiff::compute(&old, &buffer);
1409
1410        presenter
1411            .present_with_pool(&buffer, &diff, None, Some(&links))
1412            .unwrap();
1413        let output = get_output(presenter);
1414
1415        // The close sequence should appear (frame end cleanup)
1416        let close_seq = b"\x1b]8;;\x1b\\";
1417        assert!(
1418            output.windows(close_seq.len()).any(|w| w == close_seq),
1419            "Link must be closed at frame end"
1420        );
1421    }
1422
1423    #[test]
1424    fn hyperlink_transitions_between_links() {
1425        let mut presenter = test_presenter();
1426        let mut buffer = Buffer::new(10, 1);
1427        let mut links = LinkRegistry::new();
1428
1429        let link_a = links.register("https://a.com");
1430        let link_b = links.register("https://b.com");
1431
1432        buffer.set_raw(
1433            0,
1434            0,
1435            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1436        );
1437        buffer.set_raw(
1438            1,
1439            0,
1440            Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1441        );
1442        buffer.set_raw(2, 0, Cell::from_char('C')); // no link
1443
1444        let old = Buffer::new(10, 1);
1445        let diff = BufferDiff::compute(&old, &buffer);
1446
1447        presenter
1448            .present_with_pool(&buffer, &diff, None, Some(&links))
1449            .unwrap();
1450        let output = get_output(presenter);
1451        let output_str = String::from_utf8_lossy(&output);
1452
1453        // Both links should appear
1454        assert!(output_str.contains("https://a.com"));
1455        assert!(output_str.contains("https://b.com"));
1456
1457        // Close sequence must appear at least once (transition or frame end)
1458        let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1459        assert!(
1460            close_count >= 2,
1461            "Expected at least 2 link close sequences (transition + frame end), got {}",
1462            close_count
1463        );
1464    }
1465
1466    // =========================================================================
1467    // Single-write-per-frame behavior tests
1468    // =========================================================================
1469
1470    #[test]
1471    fn sync_output_not_wrapped_when_unsupported() {
1472        // When sync_output capability is false, sync sequences should NOT appear
1473        let mut presenter = test_presenter(); // basic caps, sync_output = false
1474        let buffer = Buffer::new(10, 10);
1475        let diff = BufferDiff::new();
1476
1477        presenter.present(&buffer, &diff).unwrap();
1478        let output = get_output(presenter);
1479
1480        // Should NOT contain sync sequences
1481        assert!(
1482            !output.starts_with(ansi::SYNC_BEGIN),
1483            "Sync begin should not appear when sync_output is disabled"
1484        );
1485        assert!(
1486            !output
1487                .windows(ansi::SYNC_END.len())
1488                .any(|w| w == ansi::SYNC_END),
1489            "Sync end should not appear when sync_output is disabled"
1490        );
1491    }
1492
1493    #[test]
1494    fn present_flushes_buffered_output() {
1495        // Verify that present() flushes all buffered output by checking
1496        // that the output contains expected content after present()
1497        let mut presenter = test_presenter();
1498        let mut buffer = Buffer::new(5, 1);
1499        buffer.set_raw(0, 0, Cell::from_char('T'));
1500        buffer.set_raw(1, 0, Cell::from_char('E'));
1501        buffer.set_raw(2, 0, Cell::from_char('S'));
1502        buffer.set_raw(3, 0, Cell::from_char('T'));
1503
1504        let old = Buffer::new(5, 1);
1505        let diff = BufferDiff::compute(&old, &buffer);
1506
1507        presenter.present(&buffer, &diff).unwrap();
1508        let output = get_output(presenter);
1509        let output_str = String::from_utf8_lossy(&output);
1510
1511        // All characters should be present in output (flushed)
1512        assert!(
1513            output_str.contains("TEST"),
1514            "Expected 'TEST' in flushed output"
1515        );
1516    }
1517
1518    #[test]
1519    fn present_stats_reports_cells_and_bytes() {
1520        let mut presenter = test_presenter();
1521        let mut buffer = Buffer::new(10, 1);
1522
1523        // Set 5 cells
1524        for i in 0..5 {
1525            buffer.set_raw(i, 0, Cell::from_char('X'));
1526        }
1527
1528        let old = Buffer::new(10, 1);
1529        let diff = BufferDiff::compute(&old, &buffer);
1530
1531        let stats = presenter.present(&buffer, &diff).unwrap();
1532
1533        // Stats should reflect the changes
1534        assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
1535        assert!(stats.bytes_emitted > 0, "Expected some bytes written");
1536        assert!(stats.run_count >= 1, "Expected at least 1 run");
1537    }
1538
1539    // =========================================================================
1540    // Cursor tracking tests
1541    // =========================================================================
1542
1543    #[test]
1544    fn cursor_tracking_after_wide_char() {
1545        let mut presenter = test_presenter();
1546        presenter.cursor_x = Some(0);
1547        presenter.cursor_y = Some(0);
1548
1549        let mut buffer = Buffer::new(10, 1);
1550        // Wide char at x=0 should advance cursor by 2
1551        buffer.set_raw(0, 0, Cell::from_char('中'));
1552        buffer.set_raw(1, 0, Cell::CONTINUATION);
1553        // Narrow char at x=2
1554        buffer.set_raw(2, 0, Cell::from_char('A'));
1555
1556        let old = Buffer::new(10, 1);
1557        let diff = BufferDiff::compute(&old, &buffer);
1558
1559        presenter.present(&buffer, &diff).unwrap();
1560
1561        // After presenting, cursor should be at x=3 (0 + 2 for wide + 1 for 'A')
1562        // Note: cursor_x gets reset during present(), but we can verify output order
1563        let output = get_output(presenter);
1564        let output_str = String::from_utf8_lossy(&output);
1565
1566        // Both characters should appear
1567        assert!(output_str.contains('中'));
1568        assert!(output_str.contains('A'));
1569    }
1570
1571    #[test]
1572    fn cursor_position_after_multiple_runs() {
1573        let mut presenter = test_presenter();
1574        let mut buffer = Buffer::new(20, 3);
1575
1576        // Create two separate runs on different rows
1577        buffer.set_raw(0, 0, Cell::from_char('A'));
1578        buffer.set_raw(1, 0, Cell::from_char('B'));
1579        buffer.set_raw(5, 2, Cell::from_char('X'));
1580        buffer.set_raw(6, 2, Cell::from_char('Y'));
1581
1582        let old = Buffer::new(20, 3);
1583        let diff = BufferDiff::compute(&old, &buffer);
1584
1585        presenter.present(&buffer, &diff).unwrap();
1586        let output = get_output(presenter);
1587        let output_str = String::from_utf8_lossy(&output);
1588
1589        // All characters should be present
1590        assert!(output_str.contains('A'));
1591        assert!(output_str.contains('B'));
1592        assert!(output_str.contains('X'));
1593        assert!(output_str.contains('Y'));
1594
1595        // Should have multiple CUP sequences (one per run)
1596        let cup_count = output_str.matches("\x1b[").count();
1597        assert!(
1598            cup_count >= 2,
1599            "Expected at least 2 escape sequences for multiple runs"
1600        );
1601    }
1602
1603    // =========================================================================
1604    // Style tracking tests
1605    // =========================================================================
1606
1607    #[test]
1608    fn style_with_all_flags() {
1609        let mut presenter = test_presenter();
1610        let mut buffer = Buffer::new(5, 1);
1611
1612        // Create a cell with all style flags
1613        let all_flags = StyleFlags::BOLD
1614            | StyleFlags::DIM
1615            | StyleFlags::ITALIC
1616            | StyleFlags::UNDERLINE
1617            | StyleFlags::BLINK
1618            | StyleFlags::REVERSE
1619            | StyleFlags::STRIKETHROUGH;
1620
1621        let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
1622        buffer.set_raw(0, 0, cell);
1623
1624        let old = Buffer::new(5, 1);
1625        let diff = BufferDiff::compute(&old, &buffer);
1626
1627        presenter.present(&buffer, &diff).unwrap();
1628        let output = get_output(presenter);
1629        let output_str = String::from_utf8_lossy(&output);
1630
1631        // Should contain the character and SGR sequences
1632        assert!(output_str.contains('X'));
1633        // Should have SGR with multiple attributes (1;2;3;4;5;7;9m pattern)
1634        assert!(output_str.contains("\x1b["), "Expected SGR sequences");
1635    }
1636
1637    #[test]
1638    fn style_transitions_between_different_colors() {
1639        let mut presenter = test_presenter();
1640        let mut buffer = Buffer::new(3, 1);
1641
1642        // Three cells with different foreground colors
1643        buffer.set_raw(
1644            0,
1645            0,
1646            Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
1647        );
1648        buffer.set_raw(
1649            1,
1650            0,
1651            Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
1652        );
1653        buffer.set_raw(
1654            2,
1655            0,
1656            Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
1657        );
1658
1659        let old = Buffer::new(3, 1);
1660        let diff = BufferDiff::compute(&old, &buffer);
1661
1662        presenter.present(&buffer, &diff).unwrap();
1663        let output = get_output(presenter);
1664        let output_str = String::from_utf8_lossy(&output);
1665
1666        // All colors should appear in the output
1667        assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
1668        assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
1669        assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
1670    }
1671
1672    // =========================================================================
1673    // Link tracking tests
1674    // =========================================================================
1675
1676    #[test]
1677    fn link_at_buffer_boundaries() {
1678        let mut presenter = test_presenter();
1679        let mut buffer = Buffer::new(5, 1);
1680        let mut links = LinkRegistry::new();
1681
1682        let link_id = links.register("https://boundary.test");
1683
1684        // Link at first cell
1685        buffer.set_raw(
1686            0,
1687            0,
1688            Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1689        );
1690        // Link at last cell
1691        buffer.set_raw(
1692            4,
1693            0,
1694            Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1695        );
1696
1697        let old = Buffer::new(5, 1);
1698        let diff = BufferDiff::compute(&old, &buffer);
1699
1700        presenter
1701            .present_with_pool(&buffer, &diff, None, Some(&links))
1702            .unwrap();
1703        let output = get_output(presenter);
1704        let output_str = String::from_utf8_lossy(&output);
1705
1706        // Link URL should appear
1707        assert!(output_str.contains("https://boundary.test"));
1708        // Characters should appear
1709        assert!(output_str.contains('F'));
1710        assert!(output_str.contains('L'));
1711    }
1712
1713    #[test]
1714    fn link_state_cleared_after_reset() {
1715        let mut presenter = test_presenter();
1716        let mut links = LinkRegistry::new();
1717        let link_id = links.register("https://example.com");
1718
1719        // Simulate having an open link
1720        presenter.current_link = Some(link_id);
1721        presenter.current_style = Some(CellStyle::default());
1722        presenter.cursor_x = Some(5);
1723        presenter.cursor_y = Some(3);
1724
1725        presenter.reset();
1726
1727        // All state should be cleared
1728        assert!(
1729            presenter.current_link.is_none(),
1730            "current_link should be None after reset"
1731        );
1732        assert!(
1733            presenter.current_style.is_none(),
1734            "current_style should be None after reset"
1735        );
1736        assert!(
1737            presenter.cursor_x.is_none(),
1738            "cursor_x should be None after reset"
1739        );
1740        assert!(
1741            presenter.cursor_y.is_none(),
1742            "cursor_y should be None after reset"
1743        );
1744    }
1745
1746    #[test]
1747    fn link_transitions_linked_unlinked_linked() {
1748        let mut presenter = test_presenter();
1749        let mut buffer = Buffer::new(5, 1);
1750        let mut links = LinkRegistry::new();
1751
1752        let link_id = links.register("https://toggle.test");
1753
1754        // Linked -> Unlinked -> Linked pattern
1755        buffer.set_raw(
1756            0,
1757            0,
1758            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1759        );
1760        buffer.set_raw(1, 0, Cell::from_char('B')); // no link
1761        buffer.set_raw(
1762            2,
1763            0,
1764            Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1765        );
1766
1767        let old = Buffer::new(5, 1);
1768        let diff = BufferDiff::compute(&old, &buffer);
1769
1770        presenter
1771            .present_with_pool(&buffer, &diff, None, Some(&links))
1772            .unwrap();
1773        let output = get_output(presenter);
1774        let output_str = String::from_utf8_lossy(&output);
1775
1776        // Link URL should appear at least twice (once for A, once for C)
1777        let url_count = output_str.matches("https://toggle.test").count();
1778        assert!(
1779            url_count >= 2,
1780            "Expected link to open at least twice, got {} occurrences",
1781            url_count
1782        );
1783
1784        // Close sequence should appear (after A, and at frame end)
1785        let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1786        assert!(
1787            close_count >= 2,
1788            "Expected at least 2 link closes, got {}",
1789            close_count
1790        );
1791    }
1792
1793    // =========================================================================
1794    // Multiple frame tests
1795    // =========================================================================
1796
1797    #[test]
1798    fn multiple_presents_maintain_correct_state() {
1799        let mut presenter = test_presenter();
1800        let mut buffer = Buffer::new(10, 1);
1801
1802        // First frame
1803        buffer.set_raw(0, 0, Cell::from_char('1'));
1804        let old = Buffer::new(10, 1);
1805        let diff = BufferDiff::compute(&old, &buffer);
1806        presenter.present(&buffer, &diff).unwrap();
1807
1808        // Second frame - change a different cell
1809        let prev = buffer.clone();
1810        buffer.set_raw(1, 0, Cell::from_char('2'));
1811        let diff = BufferDiff::compute(&prev, &buffer);
1812        presenter.present(&buffer, &diff).unwrap();
1813
1814        // Third frame - change another cell
1815        let prev = buffer.clone();
1816        buffer.set_raw(2, 0, Cell::from_char('3'));
1817        let diff = BufferDiff::compute(&prev, &buffer);
1818        presenter.present(&buffer, &diff).unwrap();
1819
1820        let output = get_output(presenter);
1821        let output_str = String::from_utf8_lossy(&output);
1822
1823        // All numbers should appear in final output
1824        assert!(output_str.contains('1'));
1825        assert!(output_str.contains('2'));
1826        assert!(output_str.contains('3'));
1827    }
1828
1829    // =========================================================================
1830    // SGR Delta Engine tests (bd-4kq0.2.1)
1831    // =========================================================================
1832
1833    #[test]
1834    fn sgr_delta_fg_only_change_no_reset() {
1835        // When only fg changes, delta should NOT emit reset
1836        let mut presenter = test_presenter();
1837        let mut buffer = Buffer::new(3, 1);
1838
1839        let fg1 = PackedRgba::rgb(255, 0, 0);
1840        let fg2 = PackedRgba::rgb(0, 255, 0);
1841        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
1842        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
1843
1844        let old = Buffer::new(3, 1);
1845        let diff = BufferDiff::compute(&old, &buffer);
1846
1847        presenter.present(&buffer, &diff).unwrap();
1848        let output = get_output(presenter);
1849        let output_str = String::from_utf8_lossy(&output);
1850
1851        // Count SGR resets - the first cell needs a reset (from None state),
1852        // but the second cell should use delta (no reset)
1853        let reset_count = output_str.matches("\x1b[0m").count();
1854        // One reset at start (for first cell from unknown state) + one at frame end
1855        assert_eq!(
1856            reset_count, 2,
1857            "Expected 2 resets (initial + frame end), got {} in: {:?}",
1858            reset_count, output_str
1859        );
1860    }
1861
1862    #[test]
1863    fn sgr_delta_bg_only_change_no_reset() {
1864        let mut presenter = test_presenter();
1865        let mut buffer = Buffer::new(3, 1);
1866
1867        let bg1 = PackedRgba::rgb(0, 0, 255);
1868        let bg2 = PackedRgba::rgb(255, 255, 0);
1869        buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
1870        buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
1871
1872        let old = Buffer::new(3, 1);
1873        let diff = BufferDiff::compute(&old, &buffer);
1874
1875        presenter.present(&buffer, &diff).unwrap();
1876        let output = get_output(presenter);
1877        let output_str = String::from_utf8_lossy(&output);
1878
1879        // Only 2 resets: initial cell + frame end
1880        let reset_count = output_str.matches("\x1b[0m").count();
1881        assert_eq!(
1882            reset_count, 2,
1883            "Expected 2 resets, got {} in: {:?}",
1884            reset_count, output_str
1885        );
1886    }
1887
1888    #[test]
1889    fn sgr_delta_attr_addition_no_reset() {
1890        let mut presenter = test_presenter();
1891        let mut buffer = Buffer::new(3, 1);
1892
1893        // First cell: bold. Second cell: bold + italic
1894        let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
1895        let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
1896        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1897        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1898
1899        let old = Buffer::new(3, 1);
1900        let diff = BufferDiff::compute(&old, &buffer);
1901
1902        presenter.present(&buffer, &diff).unwrap();
1903        let output = get_output(presenter);
1904        let output_str = String::from_utf8_lossy(&output);
1905
1906        // Second cell should add italic (code 3) without reset
1907        let reset_count = output_str.matches("\x1b[0m").count();
1908        assert_eq!(
1909            reset_count, 2,
1910            "Expected 2 resets, got {} in: {:?}",
1911            reset_count, output_str
1912        );
1913        // Should contain italic-on code for the delta
1914        assert!(
1915            output_str.contains("\x1b[3m"),
1916            "Expected italic-on sequence in: {:?}",
1917            output_str
1918        );
1919    }
1920
1921    #[test]
1922    fn sgr_delta_attr_removal_uses_off_code() {
1923        let mut presenter = test_presenter();
1924        let mut buffer = Buffer::new(3, 1);
1925
1926        // First cell: bold+italic. Second cell: bold only
1927        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
1928        let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
1929        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1930        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1931
1932        let old = Buffer::new(3, 1);
1933        let diff = BufferDiff::compute(&old, &buffer);
1934
1935        presenter.present(&buffer, &diff).unwrap();
1936        let output = get_output(presenter);
1937        let output_str = String::from_utf8_lossy(&output);
1938
1939        // Should contain italic-off code (23) for delta
1940        assert!(
1941            output_str.contains("\x1b[23m"),
1942            "Expected italic-off sequence in: {:?}",
1943            output_str
1944        );
1945        // Only 2 resets (initial + frame end), not 3
1946        let reset_count = output_str.matches("\x1b[0m").count();
1947        assert_eq!(
1948            reset_count, 2,
1949            "Expected 2 resets, got {} in: {:?}",
1950            reset_count, output_str
1951        );
1952    }
1953
1954    #[test]
1955    fn sgr_delta_bold_dim_collateral_re_enables() {
1956        // Bold off (code 22) also disables Dim. If Dim should remain,
1957        // the delta engine must re-enable it.
1958        let mut presenter = test_presenter();
1959        let mut buffer = Buffer::new(3, 1);
1960
1961        // First cell: Bold + Dim. Second cell: Dim only
1962        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
1963        let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
1964        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1965        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1966
1967        let old = Buffer::new(3, 1);
1968        let diff = BufferDiff::compute(&old, &buffer);
1969
1970        presenter.present(&buffer, &diff).unwrap();
1971        let output = get_output(presenter);
1972        let output_str = String::from_utf8_lossy(&output);
1973
1974        // Should contain bold-off (22) and then dim re-enable (2)
1975        assert!(
1976            output_str.contains("\x1b[22m"),
1977            "Expected bold-off (22) in: {:?}",
1978            output_str
1979        );
1980        assert!(
1981            output_str.contains("\x1b[2m"),
1982            "Expected dim re-enable (2) in: {:?}",
1983            output_str
1984        );
1985    }
1986
1987    #[test]
1988    fn sgr_delta_same_style_no_output() {
1989        let mut presenter = test_presenter();
1990        let mut buffer = Buffer::new(3, 1);
1991
1992        let fg = PackedRgba::rgb(255, 0, 0);
1993        let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
1994        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
1995        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
1996        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
1997
1998        let old = Buffer::new(3, 1);
1999        let diff = BufferDiff::compute(&old, &buffer);
2000
2001        presenter.present(&buffer, &diff).unwrap();
2002        let output = get_output(presenter);
2003        let output_str = String::from_utf8_lossy(&output);
2004
2005        // Only 1 fg color sequence (style set once for all three cells)
2006        let fg_count = output_str.matches("38;2;255;0;0").count();
2007        assert_eq!(
2008            fg_count, 1,
2009            "Expected 1 fg sequence, got {} in: {:?}",
2010            fg_count, output_str
2011        );
2012    }
2013
2014    #[test]
2015    fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2016        // Test that delta output is never larger than reset+apply would be
2017        // for a variety of style transitions
2018        let transitions: Vec<(CellStyle, CellStyle)> = vec![
2019            // Only fg change
2020            (
2021                CellStyle {
2022                    fg: PackedRgba::rgb(255, 0, 0),
2023                    bg: PackedRgba::TRANSPARENT,
2024                    attrs: StyleFlags::empty(),
2025                },
2026                CellStyle {
2027                    fg: PackedRgba::rgb(0, 255, 0),
2028                    bg: PackedRgba::TRANSPARENT,
2029                    attrs: StyleFlags::empty(),
2030                },
2031            ),
2032            // Only bg change
2033            (
2034                CellStyle {
2035                    fg: PackedRgba::TRANSPARENT,
2036                    bg: PackedRgba::rgb(255, 0, 0),
2037                    attrs: StyleFlags::empty(),
2038                },
2039                CellStyle {
2040                    fg: PackedRgba::TRANSPARENT,
2041                    bg: PackedRgba::rgb(0, 0, 255),
2042                    attrs: StyleFlags::empty(),
2043                },
2044            ),
2045            // Only attr addition
2046            (
2047                CellStyle {
2048                    fg: PackedRgba::rgb(100, 100, 100),
2049                    bg: PackedRgba::TRANSPARENT,
2050                    attrs: StyleFlags::BOLD,
2051                },
2052                CellStyle {
2053                    fg: PackedRgba::rgb(100, 100, 100),
2054                    bg: PackedRgba::TRANSPARENT,
2055                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2056                },
2057            ),
2058            // Attr removal
2059            (
2060                CellStyle {
2061                    fg: PackedRgba::rgb(100, 100, 100),
2062                    bg: PackedRgba::TRANSPARENT,
2063                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2064                },
2065                CellStyle {
2066                    fg: PackedRgba::rgb(100, 100, 100),
2067                    bg: PackedRgba::TRANSPARENT,
2068                    attrs: StyleFlags::BOLD,
2069                },
2070            ),
2071        ];
2072
2073        for (old_style, new_style) in &transitions {
2074            // Measure delta cost
2075            let delta_buf = {
2076                let mut delta_presenter = {
2077                    let caps = TerminalCapabilities::basic();
2078                    Presenter::new(Vec::new(), caps)
2079                };
2080                delta_presenter.current_style = Some(*old_style);
2081                delta_presenter
2082                    .emit_style_delta(*old_style, *new_style)
2083                    .unwrap();
2084                delta_presenter.into_inner().unwrap()
2085            };
2086
2087            // Measure reset+apply cost
2088            let reset_buf = {
2089                let mut reset_presenter = {
2090                    let caps = TerminalCapabilities::basic();
2091                    Presenter::new(Vec::new(), caps)
2092                };
2093                reset_presenter.emit_style_full(*new_style).unwrap();
2094                reset_presenter.into_inner().unwrap()
2095            };
2096
2097            assert!(
2098                delta_buf.len() <= reset_buf.len(),
2099                "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2100                 Delta: {:?}\nReset: {:?}",
2101                delta_buf.len(),
2102                reset_buf.len(),
2103                old_style,
2104                new_style,
2105                String::from_utf8_lossy(&delta_buf),
2106                String::from_utf8_lossy(&reset_buf),
2107            );
2108        }
2109    }
2110
2111    /// Generate a deterministic JSONL evidence ledger proving the SGR delta engine
2112    /// emits fewer (or equal) bytes than reset+apply for every transition.
2113    ///
2114    /// Each line is a JSON object with:
2115    ///   seed, from_fg, from_bg, from_attrs, to_fg, to_bg, to_attrs,
2116    ///   delta_bytes, baseline_bytes, cost_delta, used_fallback
2117    #[test]
2118    fn sgr_delta_evidence_ledger() {
2119        use std::io::Write as _;
2120
2121        // Deterministic seed for reproducibility
2122        const SEED: u64 = 0xDEAD_BEEF_CAFE;
2123
2124        // Simple LCG for deterministic pseudorandom values
2125        let mut rng_state = SEED;
2126        let mut next_u64 = || -> u64 {
2127            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2128            rng_state
2129        };
2130
2131        let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2132            let v = rng();
2133            let fg = if v & 1 == 0 {
2134                PackedRgba::TRANSPARENT
2135            } else {
2136                let r = ((v >> 8) & 0xFF) as u8;
2137                let g = ((v >> 16) & 0xFF) as u8;
2138                let b = ((v >> 24) & 0xFF) as u8;
2139                PackedRgba::rgb(r, g, b)
2140            };
2141            let v2 = rng();
2142            let bg = if v2 & 1 == 0 {
2143                PackedRgba::TRANSPARENT
2144            } else {
2145                let r = ((v2 >> 8) & 0xFF) as u8;
2146                let g = ((v2 >> 16) & 0xFF) as u8;
2147                let b = ((v2 >> 24) & 0xFF) as u8;
2148                PackedRgba::rgb(r, g, b)
2149            };
2150            let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2151            CellStyle { fg, bg, attrs }
2152        };
2153
2154        let mut ledger = Vec::new();
2155        let num_transitions = 200;
2156
2157        for i in 0..num_transitions {
2158            let old_style = random_style(&mut next_u64);
2159            let new_style = random_style(&mut next_u64);
2160
2161            // Measure delta cost
2162            let mut delta_p = {
2163                let caps = TerminalCapabilities::basic();
2164                Presenter::new(Vec::new(), caps)
2165            };
2166            delta_p.current_style = Some(old_style);
2167            delta_p.emit_style_delta(old_style, new_style).unwrap();
2168            let delta_out = delta_p.into_inner().unwrap();
2169
2170            // Measure reset+apply cost
2171            let mut reset_p = {
2172                let caps = TerminalCapabilities::basic();
2173                Presenter::new(Vec::new(), caps)
2174            };
2175            reset_p.emit_style_full(new_style).unwrap();
2176            let reset_out = reset_p.into_inner().unwrap();
2177
2178            let delta_bytes = delta_out.len();
2179            let baseline_bytes = reset_out.len();
2180
2181            // Compute whether fallback was used (delta >= baseline means fallback likely)
2182            let attrs_removed = old_style.attrs & !new_style.attrs;
2183            let removed_count = attrs_removed.bits().count_ones();
2184            let fg_changed = old_style.fg != new_style.fg;
2185            let bg_changed = old_style.bg != new_style.bg;
2186            let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2187
2188            // Assert cost dominance
2189            assert!(
2190                delta_bytes <= baseline_bytes,
2191                "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2192            );
2193
2194            // Emit JSONL record
2195            writeln!(
2196                &mut ledger,
2197                "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2198                 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2199                 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2200                 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2201                old_style.fg,
2202                old_style.bg,
2203                old_style.attrs.bits(),
2204                new_style.fg,
2205                new_style.bg,
2206                new_style.attrs.bits(),
2207                baseline_bytes as isize - delta_bytes as isize,
2208            )
2209            .unwrap();
2210        }
2211
2212        // Verify we produced valid JSONL (every line parses)
2213        let text = String::from_utf8(ledger).unwrap();
2214        let lines: Vec<&str> = text.lines().collect();
2215        assert_eq!(lines.len(), num_transitions);
2216
2217        // Verify aggregate: total savings should be non-negative
2218        let mut total_saved: isize = 0;
2219        for line in &lines {
2220            // Quick parse of cost_delta field
2221            let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2222            let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2223            let cd: isize = line[cd_start..cd_end].parse().unwrap();
2224            total_saved += cd;
2225        }
2226        assert!(
2227            total_saved >= 0,
2228            "Total byte savings should be non-negative, got {total_saved}"
2229        );
2230    }
2231
2232    /// E2E style stress test: scripted style churn across a full buffer
2233    /// with byte metrics proving delta engine correctness under load.
2234    #[test]
2235    fn e2e_style_stress_with_byte_metrics() {
2236        let width = 40u16;
2237        let height = 10u16;
2238
2239        // Build a buffer with maximum style diversity
2240        let mut buffer = Buffer::new(width, height);
2241        for y in 0..height {
2242            for x in 0..width {
2243                let i = (y as usize * width as usize + x as usize) as u8;
2244                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2245                let bg = if i.is_multiple_of(4) {
2246                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2247                } else {
2248                    PackedRgba::TRANSPARENT
2249                };
2250                let flags = StyleFlags::from_bits_truncate(i % 128);
2251                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2252                let cell = Cell::from_char(ch)
2253                    .with_fg(fg)
2254                    .with_bg(bg)
2255                    .with_attrs(CellAttrs::new(flags, 0));
2256                buffer.set_raw(x, y, cell);
2257            }
2258        }
2259
2260        // Present from blank (first frame)
2261        let blank = Buffer::new(width, height);
2262        let diff = BufferDiff::compute(&blank, &buffer);
2263        let mut presenter = test_presenter();
2264        presenter.present(&buffer, &diff).unwrap();
2265        let frame1_bytes = presenter.into_inner().unwrap().len();
2266
2267        // Build second buffer: shift all styles by one position (churn)
2268        let mut buffer2 = Buffer::new(width, height);
2269        for y in 0..height {
2270            for x in 0..width {
2271                let i = (y as usize * width as usize + x as usize + 1) as u8;
2272                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2273                let bg = if i.is_multiple_of(4) {
2274                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2275                } else {
2276                    PackedRgba::TRANSPARENT
2277                };
2278                let flags = StyleFlags::from_bits_truncate(i % 128);
2279                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2280                let cell = Cell::from_char(ch)
2281                    .with_fg(fg)
2282                    .with_bg(bg)
2283                    .with_attrs(CellAttrs::new(flags, 0));
2284                buffer2.set_raw(x, y, cell);
2285            }
2286        }
2287
2288        // Second frame: incremental update should use delta engine
2289        let diff2 = BufferDiff::compute(&buffer, &buffer2);
2290        let mut presenter2 = test_presenter();
2291        presenter2.present(&buffer2, &diff2).unwrap();
2292        let frame2_bytes = presenter2.into_inner().unwrap().len();
2293
2294        // Incremental should be smaller than full redraw since delta
2295        // engine can reuse partial style state
2296        assert!(
2297            frame2_bytes > 0,
2298            "Second frame should produce output for style churn"
2299        );
2300        assert!(!diff2.is_empty(), "Style shift should produce changes");
2301
2302        // Verify frame2 is at most frame1 size (delta should never be worse
2303        // than a full redraw for the same number of changed cells)
2304        // Note: frame2 may differ in size due to different diff (changed cells
2305        // vs all cells), so just verify it's reasonable.
2306        assert!(
2307            frame2_bytes <= frame1_bytes * 2,
2308            "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2309        );
2310    }
2311
2312    // =========================================================================
2313    // DP Cost Model Tests (bd-4kq0.2.2)
2314    // =========================================================================
2315
2316    #[test]
2317    fn cost_model_empty_row_single_run() {
2318        // Single run on a row should always use Sparse (no merge benefit)
2319        let runs = [ChangeRun::new(5, 10, 20)];
2320        let plan = cost_model::plan_row(&runs, None, None);
2321        assert_eq!(plan.spans().len(), 1);
2322        assert_eq!(plan.spans()[0].x0, 10);
2323        assert_eq!(plan.spans()[0].x1, 20);
2324        assert!(plan.total_cost() > 0);
2325    }
2326
2327    #[test]
2328    fn cost_model_full_row_merges() {
2329        // Two small runs far apart on same row - gap is smaller than 2x CUP overhead
2330        // Runs at columns 0-2 and 77-79 on an 80-col row
2331        // Sparse: CUP + 3 cells + CUP + 3 cells
2332        // Merged: CUP + 80 cells but with gap overhead
2333        // This should stay sparse since the gap is very large
2334        let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2335        let plan = cost_model::plan_row(&runs, None, None);
2336        // Large gap (74 cells * 2 overhead = 148) vs CUP savings (~8) => no merge.
2337        assert_eq!(plan.spans().len(), 2);
2338        assert_eq!(plan.spans()[0].x0, 0);
2339        assert_eq!(plan.spans()[0].x1, 2);
2340        assert_eq!(plan.spans()[1].x0, 77);
2341        assert_eq!(plan.spans()[1].x1, 79);
2342    }
2343
2344    #[test]
2345    fn cost_model_adjacent_runs_merge() {
2346        // Many single-cell runs with 1-cell gaps should merge
2347        // 8 single-cell runs at columns 10, 12, 14, 16, 18, 20, 22, 24
2348        let runs = [
2349            ChangeRun::new(3, 10, 10),
2350            ChangeRun::new(3, 12, 12),
2351            ChangeRun::new(3, 14, 14),
2352            ChangeRun::new(3, 16, 16),
2353            ChangeRun::new(3, 18, 18),
2354            ChangeRun::new(3, 20, 20),
2355            ChangeRun::new(3, 22, 22),
2356            ChangeRun::new(3, 24, 24),
2357        ];
2358        let plan = cost_model::plan_row(&runs, None, None);
2359        // Sparse: 1 CUP + 7 CUF(2) * 4 bytes + 8 cells = ~7+28+8 = 43
2360        // Merged: 1 CUP + 8 changed + 7 gap * 2 = 7+8+14 = 29
2361        assert_eq!(plan.spans().len(), 1);
2362        assert_eq!(plan.spans()[0].x0, 10);
2363        assert_eq!(plan.spans()[0].x1, 24);
2364    }
2365
2366    #[test]
2367    fn cost_model_single_cell_stays_sparse() {
2368        let runs = [ChangeRun::new(0, 40, 40)];
2369        let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2370        assert_eq!(plan.spans().len(), 1);
2371        assert_eq!(plan.spans()[0].x0, 40);
2372        assert_eq!(plan.spans()[0].x1, 40);
2373    }
2374
2375    #[test]
2376    fn cost_model_cup_vs_cha_vs_cuf() {
2377        // CUF should be cheapest for small forward moves on same row
2378        assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
2379        assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
2380
2381        // CHA should be cheapest for backward moves on same row (vs CUP)
2382        let cha = cost_model::cha_cost(5);
2383        let cup = cost_model::cup_cost(0, 5);
2384        assert!(cha <= cup);
2385
2386        // Cheapest move from known position (same row, forward 1)
2387        let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
2388        assert_eq!(cost, 3); // CUF(1) = "\x1b[C" = 3 bytes
2389    }
2390
2391    #[test]
2392    fn cost_model_digit_estimation_accuracy() {
2393        // Verify CUP cost estimates are accurate by comparing to actual output
2394        let mut buf = Vec::new();
2395        ansi::cup(&mut buf, 0, 0).unwrap();
2396        assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
2397
2398        buf.clear();
2399        ansi::cup(&mut buf, 9, 9).unwrap();
2400        assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
2401
2402        buf.clear();
2403        ansi::cup(&mut buf, 99, 99).unwrap();
2404        assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
2405
2406        buf.clear();
2407        ansi::cha(&mut buf, 0).unwrap();
2408        assert_eq!(buf.len(), cost_model::cha_cost(0));
2409
2410        buf.clear();
2411        ansi::cuf(&mut buf, 1).unwrap();
2412        assert_eq!(buf.len(), cost_model::cuf_cost(1));
2413
2414        buf.clear();
2415        ansi::cuf(&mut buf, 10).unwrap();
2416        assert_eq!(buf.len(), cost_model::cuf_cost(10));
2417    }
2418
2419    #[test]
2420    fn cost_model_merged_row_produces_correct_output() {
2421        // Verify that merged emission produces the same visual result as sparse
2422        let width = 30u16;
2423        let mut buffer = Buffer::new(width, 1);
2424
2425        // Set up scattered changes: columns 5, 10, 15, 20
2426        for col in [5u16, 10, 15, 20] {
2427            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2428            buffer.set_raw(col, 0, Cell::from_char(ch));
2429        }
2430
2431        let old = Buffer::new(width, 1);
2432        let diff = BufferDiff::compute(&old, &buffer);
2433
2434        // Present and verify output contains expected characters
2435        let mut presenter = test_presenter();
2436        presenter.present(&buffer, &diff).unwrap();
2437        let output = presenter.into_inner().unwrap();
2438        let output_str = String::from_utf8_lossy(&output);
2439
2440        for col in [5u16, 10, 15, 20] {
2441            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2442            assert!(
2443                output_str.contains(ch),
2444                "Missing character '{ch}' at col {col} in output"
2445            );
2446        }
2447    }
2448
2449    #[test]
2450    fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
2451        // Verify move_cursor_optimal uses CUF for small forward moves
2452        let mut presenter = test_presenter();
2453        presenter.cursor_x = Some(5);
2454        presenter.cursor_y = Some(0);
2455        presenter.move_cursor_optimal(6, 0).unwrap();
2456        let output = presenter.into_inner().unwrap();
2457        // CUF(1) = "\x1b[C"
2458        assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
2459    }
2460
2461    #[test]
2462    fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
2463        let mut presenter = test_presenter();
2464        presenter.cursor_x = Some(10);
2465        presenter.cursor_y = Some(3);
2466
2467        let target_x = 2;
2468        let target_y = 3;
2469        let cha_cost = cost_model::cha_cost(target_x);
2470        let cup_cost = cost_model::cup_cost(target_y, target_x);
2471        assert!(
2472            cha_cost <= cup_cost,
2473            "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
2474        );
2475
2476        presenter.move_cursor_optimal(target_x, target_y).unwrap();
2477        let output = presenter.into_inner().unwrap();
2478        let mut expected = Vec::new();
2479        ansi::cha(&mut expected, target_x).unwrap();
2480        assert_eq!(output, expected, "Should use CHA for backward move");
2481    }
2482
2483    #[test]
2484    fn cost_model_optimal_cursor_uses_cup_on_row_change() {
2485        let mut presenter = test_presenter();
2486        presenter.cursor_x = Some(4);
2487        presenter.cursor_y = Some(1);
2488
2489        presenter.move_cursor_optimal(7, 4).unwrap();
2490        let output = presenter.into_inner().unwrap();
2491        let mut expected = Vec::new();
2492        ansi::cup(&mut expected, 4, 7).unwrap();
2493        assert_eq!(output, expected, "Should use CUP when row changes");
2494    }
2495
2496    #[test]
2497    fn cost_model_chooses_full_row_when_cheaper() {
2498        // Create a scenario where merged is definitely cheaper:
2499        // 10 single-cell runs with 1-cell gaps on the same row
2500        let width = 40u16;
2501        let mut buffer = Buffer::new(width, 1);
2502
2503        // Every other column: 0, 2, 4, 6, 8, 10, 12, 14, 16, 18
2504        for col in (0..20).step_by(2) {
2505            buffer.set_raw(col, 0, Cell::from_char('X'));
2506        }
2507
2508        let old = Buffer::new(width, 1);
2509        let diff = BufferDiff::compute(&old, &buffer);
2510        let runs = diff.runs();
2511
2512        // The cost model should merge (many small gaps < many CUP costs)
2513        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2514        if row_runs.len() > 1 {
2515            let plan = cost_model::plan_row(&row_runs, None, None);
2516            assert!(
2517                plan.spans().len() == 1,
2518                "Expected single merged span for many small runs, got {} spans",
2519                plan.spans().len()
2520            );
2521            assert_eq!(plan.spans()[0].x0, 0);
2522            assert_eq!(plan.spans()[0].x1, 18);
2523        }
2524    }
2525
2526    #[test]
2527    fn perf_cost_model_overhead() {
2528        // Verify the cost model planning is fast (microsecond scale)
2529        use std::time::Instant;
2530
2531        let runs: Vec<ChangeRun> = (0..100)
2532            .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
2533            .collect();
2534
2535        let (iterations, max_ms) = if cfg!(debug_assertions) {
2536            (1_000, 1_000u128)
2537        } else {
2538            (10_000, 500u128)
2539        };
2540
2541        let start = Instant::now();
2542        for _ in 0..iterations {
2543            let _ = cost_model::plan_row(&runs, None, None);
2544        }
2545        let elapsed = start.elapsed();
2546
2547        // Keep this generous in debug builds to avoid flaky perf assertions.
2548        assert!(
2549            elapsed.as_millis() < max_ms,
2550            "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
2551        );
2552    }
2553
2554    #[test]
2555    fn perf_legacy_vs_dp_worst_case_sparse() {
2556        use std::time::Instant;
2557
2558        let width = 200u16;
2559        let height = 1u16;
2560        let mut buffer = Buffer::new(width, height);
2561
2562        // Two dense clusters with a large gap between them.
2563        for col in (0..40).step_by(2) {
2564            buffer.set_raw(col, 0, Cell::from_char('X'));
2565        }
2566        for col in (160..200).step_by(2) {
2567            buffer.set_raw(col, 0, Cell::from_char('Y'));
2568        }
2569
2570        let blank = Buffer::new(width, height);
2571        let diff = BufferDiff::compute(&blank, &buffer);
2572        let runs = diff.runs();
2573        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2574
2575        let dp_plan = cost_model::plan_row(&row_runs, None, None);
2576        let legacy_spans = legacy_plan_row(&row_runs, None, None);
2577
2578        let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
2579        let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
2580
2581        assert!(
2582            dp_output.len() <= legacy_output.len(),
2583            "DP output should be <= legacy output (dp={}, legacy={})",
2584            dp_output.len(),
2585            legacy_output.len()
2586        );
2587
2588        let (iterations, max_ms) = if cfg!(debug_assertions) {
2589            (1_000, 1_000u128)
2590        } else {
2591            (10_000, 500u128)
2592        };
2593        let start = Instant::now();
2594        for _ in 0..iterations {
2595            let _ = cost_model::plan_row(&row_runs, None, None);
2596        }
2597        let dp_elapsed = start.elapsed();
2598
2599        let start = Instant::now();
2600        for _ in 0..iterations {
2601            let _ = legacy_plan_row(&row_runs, None, None);
2602        }
2603        let legacy_elapsed = start.elapsed();
2604
2605        assert!(
2606            dp_elapsed.as_millis() < max_ms,
2607            "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
2608        );
2609
2610        let _ = legacy_elapsed;
2611    }
2612
2613    // =========================================================================
2614    // Presenter Perf + Golden Outputs (bd-4kq0.2.3)
2615    // =========================================================================
2616
2617    /// Build a deterministic "style-heavy" scene: every cell has a unique style.
2618    fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
2619        let mut buffer = Buffer::new(width, height);
2620        let mut rng = seed;
2621        let mut next = || -> u64 {
2622            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2623            rng
2624        };
2625        for y in 0..height {
2626            for x in 0..width {
2627                let v = next();
2628                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2629                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
2630                let bg = if v & 3 == 0 {
2631                    PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
2632                } else {
2633                    PackedRgba::TRANSPARENT
2634                };
2635                let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
2636                let cell = Cell::from_char(ch)
2637                    .with_fg(fg)
2638                    .with_bg(bg)
2639                    .with_attrs(CellAttrs::new(flags, 0));
2640                buffer.set_raw(x, y, cell);
2641            }
2642        }
2643        buffer
2644    }
2645
2646    /// Build a "sparse-update" scene: only ~10% of cells differ between frames.
2647    fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
2648        let mut buffer = base.clone();
2649        let width = base.width();
2650        let height = base.height();
2651        let mut rng = seed;
2652        let mut next = || -> u64 {
2653            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2654            rng
2655        };
2656        let change_count = (width as usize * height as usize) / 10;
2657        for _ in 0..change_count {
2658            let v = next();
2659            let x = (v % width as u64) as u16;
2660            let y = ((v >> 16) % height as u64) as u16;
2661            let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
2662            buffer.set_raw(x, y, Cell::from_char(ch));
2663        }
2664        buffer
2665    }
2666
2667    #[test]
2668    fn snapshot_presenter_equivalence() {
2669        // Golden snapshot: style-heavy 40x10 scene with deterministic seed.
2670        // The output hash must be stable across runs.
2671        let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
2672        let blank = Buffer::new(40, 10);
2673        let diff = BufferDiff::compute(&blank, &buffer);
2674
2675        let mut presenter = test_presenter();
2676        presenter.present(&buffer, &diff).unwrap();
2677        let output = presenter.into_inner().unwrap();
2678
2679        // Compute checksum for golden comparison
2680        let checksum = {
2681            let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
2682            for &byte in &output {
2683                hash ^= byte as u64;
2684                hash = hash.wrapping_mul(0x100000001b3); // FNV prime
2685            }
2686            hash
2687        };
2688
2689        // Verify determinism: same seed + scene = same output
2690        let mut presenter2 = test_presenter();
2691        presenter2.present(&buffer, &diff).unwrap();
2692        let output2 = presenter2.into_inner().unwrap();
2693        assert_eq!(output, output2, "Presenter output must be deterministic");
2694
2695        // Log golden checksum for the record
2696        let _ = checksum; // Used in JSONL test below
2697    }
2698
2699    #[test]
2700    fn perf_presenter_microbench() {
2701        use std::env;
2702        use std::io::Write as _;
2703        use std::time::Instant;
2704
2705        let width = 120u16;
2706        let height = 40u16;
2707        let seed = 0x00BE_EFCA_FE42;
2708        let scene = build_style_heavy_scene(width, height, seed);
2709        let blank = Buffer::new(width, height);
2710        let diff_full = BufferDiff::compute(&blank, &scene);
2711
2712        // Also build a sparse update scene
2713        let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
2714        let diff_sparse = BufferDiff::compute(&scene, &scene2);
2715
2716        let mut jsonl = Vec::new();
2717        let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
2718            .ok()
2719            .and_then(|value| value.parse::<u32>().ok())
2720            .unwrap_or(50);
2721
2722        let runs_full = diff_full.runs();
2723        let runs_sparse = diff_sparse.runs();
2724
2725        let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
2726            let mut idx = 0;
2727            let mut total_cost = 0usize;
2728            let mut span_count = 0usize;
2729            let mut prev_x = None;
2730            let mut prev_y = None;
2731
2732            while idx < runs.len() {
2733                let y = runs[idx].y;
2734                let start = idx;
2735                while idx < runs.len() && runs[idx].y == y {
2736                    idx += 1;
2737                }
2738
2739                let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
2740                span_count += plan.spans().len();
2741                total_cost = total_cost.saturating_add(plan.total_cost());
2742                if let Some(last) = plan.spans().last() {
2743                    prev_x = Some(last.x1);
2744                    prev_y = Some(y);
2745                }
2746            }
2747
2748            (total_cost, span_count)
2749        };
2750
2751        for i in 0..iterations {
2752            let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
2753                (&diff_full, &scene, &runs_full, "full")
2754            } else {
2755                (&diff_sparse, &scene2, &runs_sparse, "sparse")
2756            };
2757
2758            let plan_start = Instant::now();
2759            let (plan_cost, plan_spans) = plan_rows(runs_ref);
2760            let plan_time_us = plan_start.elapsed().as_micros() as u64;
2761
2762            let mut presenter = test_presenter();
2763            let start = Instant::now();
2764            let stats = presenter.present(buf_ref, diff_ref).unwrap();
2765            let elapsed_us = start.elapsed().as_micros() as u64;
2766            let output = presenter.into_inner().unwrap();
2767
2768            // FNV-1a checksum
2769            let checksum = {
2770                let mut hash: u64 = 0xcbf29ce484222325;
2771                for &b in &output {
2772                    hash ^= b as u64;
2773                    hash = hash.wrapping_mul(0x100000001b3);
2774                }
2775                hash
2776            };
2777
2778            writeln!(
2779                &mut jsonl,
2780                "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
2781                 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
2782                 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
2783                 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
2784                 \"emit_time_us\":{elapsed_us},\
2785                 \"checksum\":\"{checksum:016x}\"}}",
2786                stats.cells_changed, stats.run_count, stats.bytes_emitted,
2787            )
2788            .unwrap();
2789        }
2790
2791        let text = String::from_utf8(jsonl).unwrap();
2792        let lines: Vec<&str> = text.lines().collect();
2793        assert_eq!(lines.len(), iterations as usize);
2794
2795        // Parse and verify: full frames should be deterministic (same checksum)
2796        let full_checksums: Vec<&str> = lines
2797            .iter()
2798            .filter(|l| l.contains("\"full\""))
2799            .map(|l| {
2800                let start = l.find("\"checksum\":\"").unwrap() + 12;
2801                let end = l[start..].find('"').unwrap() + start;
2802                &l[start..end]
2803            })
2804            .collect();
2805        assert!(full_checksums.len() > 1);
2806        assert!(
2807            full_checksums.windows(2).all(|w| w[0] == w[1]),
2808            "Full frame checksums should be identical across runs"
2809        );
2810
2811        // Sparse frame bytes should be less than full frame bytes
2812        let full_bytes: Vec<u64> = lines
2813            .iter()
2814            .filter(|l| l.contains("\"full\""))
2815            .map(|l| {
2816                let start = l.find("\"bytes\":").unwrap() + 8;
2817                let end = l[start..].find(',').unwrap() + start;
2818                l[start..end].parse::<u64>().unwrap()
2819            })
2820            .collect();
2821        let sparse_bytes: Vec<u64> = lines
2822            .iter()
2823            .filter(|l| l.contains("\"sparse\""))
2824            .map(|l| {
2825                let start = l.find("\"bytes\":").unwrap() + 8;
2826                let end = l[start..].find(',').unwrap() + start;
2827                l[start..end].parse::<u64>().unwrap()
2828            })
2829            .collect();
2830
2831        let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
2832        let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
2833        assert!(
2834            avg_sparse < avg_full,
2835            "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
2836        );
2837    }
2838
2839    #[test]
2840    fn perf_emit_style_delta_microbench() {
2841        use std::env;
2842        use std::io::Write as _;
2843        use std::time::Instant;
2844
2845        let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
2846            .ok()
2847            .and_then(|value| value.parse::<u32>().ok())
2848            .unwrap_or(200);
2849        let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
2850        let emit_json = mode != "raw";
2851
2852        let mut styles = Vec::with_capacity(128);
2853        let mut rng = 0x00A5_A51E_AF42_u64;
2854        let mut next = || -> u64 {
2855            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2856            rng
2857        };
2858
2859        for _ in 0..128 {
2860            let v = next();
2861            let fg = PackedRgba::rgb(
2862                (v & 0xFF) as u8,
2863                ((v >> 8) & 0xFF) as u8,
2864                ((v >> 16) & 0xFF) as u8,
2865            );
2866            let bg = PackedRgba::rgb(
2867                ((v >> 24) & 0xFF) as u8,
2868                ((v >> 32) & 0xFF) as u8,
2869                ((v >> 40) & 0xFF) as u8,
2870            );
2871            let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
2872            let cell = Cell::from_char('A')
2873                .with_fg(fg)
2874                .with_bg(bg)
2875                .with_attrs(CellAttrs::new(flags, 0));
2876            styles.push(CellStyle::from_cell(&cell));
2877        }
2878
2879        let mut presenter = test_presenter();
2880        let mut jsonl = Vec::new();
2881        let mut sink = 0u64;
2882
2883        for i in 0..iterations {
2884            let old = styles[i as usize % styles.len()];
2885            let new = styles[(i as usize + 1) % styles.len()];
2886
2887            presenter.writer.reset_counter();
2888            presenter.writer.inner_mut().get_mut().clear();
2889
2890            let start = Instant::now();
2891            presenter.emit_style_delta(old, new).unwrap();
2892            let elapsed_us = start.elapsed().as_micros() as u64;
2893            let bytes = presenter.writer.bytes_written();
2894
2895            if emit_json {
2896                writeln!(
2897                    &mut jsonl,
2898                    "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
2899                )
2900                .unwrap();
2901            } else {
2902                sink = sink.wrapping_add(elapsed_us ^ bytes);
2903            }
2904        }
2905
2906        if emit_json {
2907            let text = String::from_utf8(jsonl).unwrap();
2908            let lines: Vec<&str> = text.lines().collect();
2909            assert_eq!(lines.len() as u32, iterations);
2910        } else {
2911            std::hint::black_box(sink);
2912        }
2913    }
2914
2915    #[test]
2916    fn e2e_presenter_stress_deterministic() {
2917        // Deterministic stress test: seeded style churn across multiple frames,
2918        // verifying no visual divergence via terminal model.
2919        use crate::terminal_model::TerminalModel;
2920
2921        let width = 60u16;
2922        let height = 20u16;
2923        let num_frames = 10;
2924
2925        let mut prev_buffer = Buffer::new(width, height);
2926        let mut presenter = test_presenter();
2927        let mut model = TerminalModel::new(width as usize, height as usize);
2928        let mut rng = 0x5D2E_55DE_5D42_u64;
2929        let mut next = || -> u64 {
2930            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2931            rng
2932        };
2933
2934        for _frame in 0..num_frames {
2935            // Build next frame: modify ~20% of cells each time
2936            let mut buffer = prev_buffer.clone();
2937            let changes = (width as usize * height as usize) / 5;
2938            for _ in 0..changes {
2939                let v = next();
2940                let x = (v % width as u64) as u16;
2941                let y = ((v >> 16) % height as u64) as u16;
2942                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2943                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
2944                let cell = Cell::from_char(ch).with_fg(fg);
2945                buffer.set_raw(x, y, cell);
2946            }
2947
2948            let diff = BufferDiff::compute(&prev_buffer, &buffer);
2949            presenter.present(&buffer, &diff).unwrap();
2950
2951            prev_buffer = buffer;
2952        }
2953
2954        // Get all output and verify final frame via terminal model
2955        let output = presenter.into_inner().unwrap();
2956        model.process(&output);
2957
2958        // Verify a sampling of cells match the final buffer
2959        let mut checked = 0;
2960        for y in 0..height {
2961            for x in 0..width {
2962                let buf_cell = prev_buffer.get_unchecked(x, y);
2963                if !buf_cell.is_empty()
2964                    && let Some(model_cell) = model.cell(x as usize, y as usize)
2965                {
2966                    let expected = buf_cell.content.as_char().unwrap_or(' ');
2967                    let mut buf = [0u8; 4];
2968                    let expected_str = expected.encode_utf8(&mut buf);
2969                    if model_cell.text.as_str() == expected_str {
2970                        checked += 1;
2971                    }
2972                }
2973            }
2974        }
2975
2976        // At least 80% of non-empty cells should match (some may be
2977        // overwritten by cursor positioning sequences in the model)
2978        let total_nonempty = (0..height)
2979            .flat_map(|y| (0..width).map(move |x| (x, y)))
2980            .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
2981            .count();
2982
2983        assert!(
2984            checked > total_nonempty * 80 / 100,
2985            "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
2986        );
2987    }
2988
2989    #[test]
2990    fn style_state_persists_across_frames() {
2991        let mut presenter = test_presenter();
2992        let fg = PackedRgba::rgb(100, 150, 200);
2993
2994        // First frame - set style
2995        let mut buffer = Buffer::new(5, 1);
2996        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
2997        let old = Buffer::new(5, 1);
2998        let diff = BufferDiff::compute(&old, &buffer);
2999        presenter.present(&buffer, &diff).unwrap();
3000
3001        // Style should be tracked (but reset at frame end per the implementation)
3002        // After present(), current_style is None due to sgr_reset at frame end
3003        assert!(
3004            presenter.current_style.is_none(),
3005            "Style should be reset after frame end"
3006        );
3007    }
3008
3009    #[test]
3010    fn zero_width_chars_replaced_with_placeholder() {
3011        let mut presenter = test_presenter();
3012        let mut buffer = Buffer::new(5, 1);
3013
3014        // U+0301 is COMBINING ACUTE ACCENT (width 0).
3015        // It is not empty, not continuation, not grapheme (unless pooled).
3016        // Storing it directly as a char means it's a standalone cell content.
3017        let zw_char = '\u{0301}';
3018
3019        // Ensure our assumption about width is correct for this environment
3020        assert_eq!(Cell::from_char(zw_char).content.width(), 0);
3021
3022        buffer.set_raw(0, 0, Cell::from_char(zw_char));
3023        buffer.set_raw(1, 0, Cell::from_char('A'));
3024
3025        let old = Buffer::new(5, 1);
3026        let diff = BufferDiff::compute(&old, &buffer);
3027
3028        presenter.present(&buffer, &diff).unwrap();
3029        let output = get_output(presenter);
3030        let output_str = String::from_utf8_lossy(&output);
3031
3032        // Should contain U+FFFD (Replacement Character)
3033        assert!(
3034            output_str.contains("\u{FFFD}"),
3035            "Expected replacement character for zero-width content, got: {:?}",
3036            output_str
3037        );
3038
3039        // Should NOT contain the raw combining mark
3040        assert!(
3041            !output_str.contains(zw_char),
3042            "Should not contain raw zero-width char"
3043        );
3044
3045        // Should contain 'A' (verify cursor sync didn't swallow it)
3046        assert!(
3047            output_str.contains('A'),
3048            "Should contain subsequent character 'A'"
3049        );
3050    }
3051}
3052
3053#[cfg(test)]
3054mod proptests {
3055    use super::*;
3056    use crate::cell::{Cell, PackedRgba};
3057    use crate::diff::BufferDiff;
3058    use crate::terminal_model::TerminalModel;
3059    use proptest::prelude::*;
3060
3061    /// Create a presenter for testing.
3062    fn test_presenter() -> Presenter<Vec<u8>> {
3063        let caps = TerminalCapabilities::basic();
3064        Presenter::new(Vec::new(), caps)
3065    }
3066
3067    proptest! {
3068        /// Property: Presenter output, when applied to terminal model, produces
3069        /// the correct characters for changed cells.
3070        #[test]
3071        fn presenter_roundtrip_characters(
3072            width in 5u16..40,
3073            height in 3u16..20,
3074            num_chars in 1usize..50, // At least 1 char to have meaningful diff
3075        ) {
3076            let mut buffer = Buffer::new(width, height);
3077            let mut changed_positions = std::collections::HashSet::new();
3078
3079            // Fill some cells with ASCII chars
3080            for i in 0..num_chars {
3081                let x = (i * 7 + 3) as u16 % width;
3082                let y = (i * 11 + 5) as u16 % height;
3083                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3084                buffer.set_raw(x, y, Cell::from_char(ch));
3085                changed_positions.insert((x, y));
3086            }
3087
3088            // Present full buffer
3089            let mut presenter = test_presenter();
3090            let old = Buffer::new(width, height);
3091            let diff = BufferDiff::compute(&old, &buffer);
3092            presenter.present(&buffer, &diff).unwrap();
3093            let output = presenter.into_inner().unwrap();
3094
3095            // Apply to terminal model
3096            let mut model = TerminalModel::new(width as usize, height as usize);
3097            model.process(&output);
3098
3099            // Verify ONLY changed characters match (model may have different default)
3100            for &(x, y) in &changed_positions {
3101                let buf_cell = buffer.get_unchecked(x, y);
3102                let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
3103                let mut expected_buf = [0u8; 4];
3104                let expected_str = expected_ch.encode_utf8(&mut expected_buf);
3105
3106                if let Some(model_cell) = model.cell(x as usize, y as usize) {
3107                    prop_assert_eq!(
3108                        model_cell.text.as_str(),
3109                        expected_str,
3110                        "Character mismatch at ({}, {})", x, y
3111                    );
3112                }
3113            }
3114        }
3115
3116        /// Property: After complete frame presentation, SGR is reset.
3117        #[test]
3118        fn style_reset_after_present(
3119            width in 5u16..30,
3120            height in 3u16..15,
3121            num_styled in 1usize..20,
3122        ) {
3123            let mut buffer = Buffer::new(width, height);
3124
3125            // Add some styled cells
3126            for i in 0..num_styled {
3127                let x = (i * 7) as u16 % width;
3128                let y = (i * 11) as u16 % height;
3129                let fg = PackedRgba::rgb(
3130                    ((i * 31) % 256) as u8,
3131                    ((i * 47) % 256) as u8,
3132                    ((i * 71) % 256) as u8,
3133                );
3134                buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
3135            }
3136
3137            // Present
3138            let mut presenter = test_presenter();
3139            let old = Buffer::new(width, height);
3140            let diff = BufferDiff::compute(&old, &buffer);
3141            presenter.present(&buffer, &diff).unwrap();
3142            let output = presenter.into_inner().unwrap();
3143            let output_str = String::from_utf8_lossy(&output);
3144
3145            // Output should end with SGR reset sequence
3146            prop_assert!(
3147                output_str.contains("\x1b[0m"),
3148                "Output should contain SGR reset"
3149            );
3150        }
3151
3152        /// Property: Presenter handles empty diff correctly.
3153        #[test]
3154        fn empty_diff_minimal_output(
3155            width in 5u16..50,
3156            height in 3u16..25,
3157        ) {
3158            let buffer = Buffer::new(width, height);
3159            let diff = BufferDiff::new(); // Empty diff
3160
3161            let mut presenter = test_presenter();
3162            presenter.present(&buffer, &diff).unwrap();
3163            let output = presenter.into_inner().unwrap();
3164
3165            // Output should only be SGR reset (or very minimal)
3166            // No cursor moves or cell content for empty diff
3167            prop_assert!(output.len() < 50, "Empty diff should have minimal output");
3168        }
3169
3170        /// Property: Full buffer change produces diff with all cells.
3171        ///
3172        /// When every cell differs, the diff should contain exactly
3173        /// width * height changes.
3174        #[test]
3175        fn diff_size_bounds(
3176            width in 5u16..30,
3177            height in 3u16..15,
3178        ) {
3179            // Full change buffer
3180            let old = Buffer::new(width, height);
3181            let mut new = Buffer::new(width, height);
3182
3183            for y in 0..height {
3184                for x in 0..width {
3185                    new.set_raw(x, y, Cell::from_char('X'));
3186                }
3187            }
3188
3189            let diff = BufferDiff::compute(&old, &new);
3190
3191            // Diff should capture all cells
3192            prop_assert_eq!(
3193                diff.len(),
3194                (width as usize) * (height as usize),
3195                "Full change should have all cells in diff"
3196            );
3197        }
3198
3199        /// Property: Presenter cursor state is consistent after operations.
3200        #[test]
3201        fn presenter_cursor_consistency(
3202            width in 10u16..40,
3203            height in 5u16..20,
3204            num_runs in 1usize..10,
3205        ) {
3206            let mut buffer = Buffer::new(width, height);
3207
3208            // Create some runs of changes
3209            for i in 0..num_runs {
3210                let start_x = (i * 5) as u16 % (width - 5);
3211                let y = i as u16 % height;
3212                for x in start_x..(start_x + 3) {
3213                    buffer.set_raw(x, y, Cell::from_char('A'));
3214                }
3215            }
3216
3217            // Multiple presents should work correctly
3218            let mut presenter = test_presenter();
3219            let old = Buffer::new(width, height);
3220
3221            for _ in 0..3 {
3222                let diff = BufferDiff::compute(&old, &buffer);
3223                presenter.present(&buffer, &diff).unwrap();
3224            }
3225
3226            // Should not panic and produce valid output
3227            let output = presenter.into_inner().unwrap();
3228            prop_assert!(!output.is_empty(), "Should produce some output");
3229        }
3230
3231        /// Property (bd-4kq0.2.1): SGR delta produces identical visual styling
3232        /// as reset+apply for random style transitions. Verified via terminal
3233        /// model roundtrip.
3234        #[test]
3235        fn sgr_delta_transition_equivalence(
3236            width in 5u16..20,
3237            height in 3u16..10,
3238            num_styled in 2usize..15,
3239        ) {
3240            let mut buffer = Buffer::new(width, height);
3241            // Track final character at each position (later writes overwrite earlier)
3242            let mut expected: std::collections::HashMap<(u16, u16), char> =
3243                std::collections::HashMap::new();
3244
3245            // Create cells with varying styles to exercise delta engine
3246            for i in 0..num_styled {
3247                let x = (i * 3 + 1) as u16 % width;
3248                let y = (i * 5 + 2) as u16 % height;
3249                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3250                let fg = PackedRgba::rgb(
3251                    ((i * 73) % 256) as u8,
3252                    ((i * 137) % 256) as u8,
3253                    ((i * 41) % 256) as u8,
3254                );
3255                let bg = if i % 3 == 0 {
3256                    PackedRgba::rgb(
3257                        ((i * 29) % 256) as u8,
3258                        ((i * 53) % 256) as u8,
3259                        ((i * 97) % 256) as u8,
3260                    )
3261                } else {
3262                    PackedRgba::TRANSPARENT
3263                };
3264                let flags_bits = ((i * 37) % 256) as u8;
3265                let flags = StyleFlags::from_bits_truncate(flags_bits);
3266                let cell = Cell::from_char(ch)
3267                    .with_fg(fg)
3268                    .with_bg(bg)
3269                    .with_attrs(CellAttrs::new(flags, 0));
3270                buffer.set_raw(x, y, cell);
3271                expected.insert((x, y), ch);
3272            }
3273
3274            // Present with delta engine
3275            let mut presenter = test_presenter();
3276            let old = Buffer::new(width, height);
3277            let diff = BufferDiff::compute(&old, &buffer);
3278            presenter.present(&buffer, &diff).unwrap();
3279            let output = presenter.into_inner().unwrap();
3280
3281            // Apply to terminal model and verify characters
3282            let mut model = TerminalModel::new(width as usize, height as usize);
3283            model.process(&output);
3284
3285            for (&(x, y), &ch) in &expected {
3286                let mut buf = [0u8; 4];
3287                let expected_str = ch.encode_utf8(&mut buf);
3288
3289                if let Some(model_cell) = model.cell(x as usize, y as usize) {
3290                    prop_assert_eq!(
3291                        model_cell.text.as_str(),
3292                        expected_str,
3293                        "Character mismatch at ({}, {}) with delta engine", x, y
3294                    );
3295                }
3296            }
3297        }
3298
3299        /// Property (bd-4kq0.2.2): DP cost model produces correct output
3300        /// regardless of which row strategy is chosen (sparse vs merged).
3301        /// Verified via terminal model roundtrip with scattered runs.
3302        #[test]
3303        fn dp_emit_equivalence(
3304            width in 20u16..60,
3305            height in 5u16..15,
3306            num_changes in 5usize..30,
3307        ) {
3308            let mut buffer = Buffer::new(width, height);
3309            let mut expected: std::collections::HashMap<(u16, u16), char> =
3310                std::collections::HashMap::new();
3311
3312            // Create scattered changes that will trigger both sparse and merged strategies
3313            for i in 0..num_changes {
3314                let x = (i * 7 + 3) as u16 % width;
3315                let y = (i * 3 + 1) as u16 % height;
3316                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3317                buffer.set_raw(x, y, Cell::from_char(ch));
3318                expected.insert((x, y), ch);
3319            }
3320
3321            // Present with DP cost model
3322            let mut presenter = test_presenter();
3323            let old = Buffer::new(width, height);
3324            let diff = BufferDiff::compute(&old, &buffer);
3325            presenter.present(&buffer, &diff).unwrap();
3326            let output = presenter.into_inner().unwrap();
3327
3328            // Apply to terminal model and verify all characters are correct
3329            let mut model = TerminalModel::new(width as usize, height as usize);
3330            model.process(&output);
3331
3332            for (&(x, y), &ch) in &expected {
3333                let mut buf = [0u8; 4];
3334                let expected_str = ch.encode_utf8(&mut buf);
3335
3336                if let Some(model_cell) = model.cell(x as usize, y as usize) {
3337                    prop_assert_eq!(
3338                        model_cell.text.as_str(),
3339                        expected_str,
3340                        "DP cost model: character mismatch at ({}, {})", x, y
3341                    );
3342                }
3343            }
3344        }
3345    }
3346}