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;
44use crate::sanitize::sanitize;
45
46pub use ftui_core::terminal_capabilities::TerminalCapabilities;
47
48/// Size of the internal write buffer (64KB).
49const BUFFER_CAPACITY: usize = 64 * 1024;
50/// Maximum hyperlink URL length allowed in OSC 8 payloads.
51const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
52
53#[inline]
54fn is_safe_hyperlink_url(url: &str) -> bool {
55    url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
56}
57
58// =============================================================================
59// DP Cost Model for ANSI Emission
60// =============================================================================
61
62/// Byte-cost estimates for ANSI cursor and output operations.
63///
64/// The cost model computes the cheapest emission plan for each row by comparing
65/// sparse-run emission (CUP per run) against merged write-through (one CUP,
66/// fill gaps with buffer content). This is a shortest-path problem on a small
67/// state graph per row.
68mod cost_model {
69    use smallvec::SmallVec;
70
71    use super::ChangeRun;
72
73    /// Number of decimal digits needed to represent `n`.
74    #[inline]
75    fn digit_count(n: u16) -> usize {
76        if n >= 10000 {
77            5
78        } else if n >= 1000 {
79            4
80        } else if n >= 100 {
81            3
82        } else if n >= 10 {
83            2
84        } else {
85            1
86        }
87    }
88
89    /// Byte cost of CUP: `\x1b[{row+1};{col+1}H`
90    #[inline]
91    pub fn cup_cost(row: u16, col: u16) -> usize {
92        // CSI (2) + row digits + ';' (1) + col digits + 'H' (1)
93        4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
94    }
95
96    /// Byte cost of CHA (column-only): `\x1b[{col+1}G`
97    #[inline]
98    pub fn cha_cost(col: u16) -> usize {
99        // CSI (2) + col digits + 'G' (1)
100        3 + digit_count(col.saturating_add(1))
101    }
102
103    /// Byte cost of CUF (cursor forward): `\x1b[{n}C` or `\x1b[C` for n=1.
104    #[inline]
105    pub fn cuf_cost(n: u16) -> usize {
106        match n {
107            0 => 0,
108            1 => 3, // \x1b[C
109            _ => 3 + digit_count(n),
110        }
111    }
112
113    /// Cheapest cursor movement cost from (from_x, from_y) to (to_x, to_y).
114    /// Returns 0 if already at the target position.
115    pub fn cheapest_move_cost(
116        from_x: Option<u16>,
117        from_y: Option<u16>,
118        to_x: u16,
119        to_y: u16,
120    ) -> usize {
121        // Already at target?
122        if from_x == Some(to_x) && from_y == Some(to_y) {
123            return 0;
124        }
125
126        let cup = cup_cost(to_y, to_x);
127
128        match (from_x, from_y) {
129            (Some(fx), Some(fy)) if fy == to_y => {
130                // Same row: compare CHA, CUF, and CUP
131                let cha = cha_cost(to_x);
132                if to_x > fx {
133                    let cuf = cuf_cost(to_x - fx);
134                    cup.min(cha).min(cuf)
135                } else if to_x == fx {
136                    0
137                } else {
138                    // Moving backward: CHA or CUP
139                    cup.min(cha)
140                }
141            }
142            _ => cup,
143        }
144    }
145
146    /// Planned contiguous span to emit on a single row.
147    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
148    pub struct RowSpan {
149        /// Row index.
150        pub y: u16,
151        /// Start column (inclusive).
152        pub x0: u16,
153        /// End column (inclusive).
154        pub x1: u16,
155    }
156
157    /// Row emission plan (possibly multiple merged spans).
158    ///
159    /// Uses SmallVec<[RowSpan; 4]> to avoid heap allocation for the common case
160    /// of 1-4 spans per row. RowSpan is 6 bytes, so 4 spans = 24 bytes inline.
161    #[derive(Debug, Clone, PartialEq, Eq)]
162    pub struct RowPlan {
163        spans: SmallVec<[RowSpan; 4]>,
164        total_cost: usize,
165    }
166
167    impl RowPlan {
168        #[inline]
169        #[must_use]
170        pub fn spans(&self) -> &[RowSpan] {
171            &self.spans
172        }
173
174        /// Total cost of this row plan (for strategy selection).
175        #[inline]
176        #[allow(dead_code)] // API for future diff strategy integration
177        pub fn total_cost(&self) -> usize {
178            self.total_cost
179        }
180    }
181
182    /// Reusable scratch buffers for `plan_row_reuse`, avoiding per-call heap
183    /// allocations. Store one instance in `Presenter` and pass it into every
184    /// `plan_row_reuse` call so that the buffers are reused across rows and
185    /// frames.
186    #[derive(Debug, Default)]
187    pub struct RowPlanScratch {
188        prefix_cells: Vec<usize>,
189        dp: Vec<usize>,
190        prev: Vec<usize>,
191    }
192
193    /// Compute the optimal emission plan for a set of runs on the same row.
194    ///
195    /// This is a shortest-path / DP partitioning problem over contiguous run
196    /// segments. Each segment may be emitted as a merged span (writing through
197    /// gaps). Single-run segments correspond to sparse emission.
198    ///
199    /// Gap cells cost ~1 byte each (character content), plus potential style
200    /// overhead estimated at 1 byte per gap cell (conservative).
201    #[allow(dead_code)]
202    pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
203        let mut scratch = RowPlanScratch::default();
204        plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
205    }
206
207    /// Like `plan_row` but reuses heap allocations via the provided scratch
208    /// buffers, eliminating per-call allocations in the hot path.
209    pub fn plan_row_reuse(
210        row_runs: &[ChangeRun],
211        prev_x: Option<u16>,
212        prev_y: Option<u16>,
213        scratch: &mut RowPlanScratch,
214    ) -> RowPlan {
215        debug_assert!(!row_runs.is_empty());
216
217        let row_y = row_runs[0].y;
218        let run_count = row_runs.len();
219
220        // Resize scratch buffers (no-op if already large enough).
221        scratch.prefix_cells.clear();
222        scratch.prefix_cells.resize(run_count + 1, 0);
223        scratch.dp.clear();
224        scratch.dp.resize(run_count, usize::MAX);
225        scratch.prev.clear();
226        scratch.prev.resize(run_count, 0);
227
228        // Prefix sum of changed cell counts for O(1) segment cost.
229        for (i, run) in row_runs.iter().enumerate() {
230            scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len() as usize;
231        }
232
233        // DP over segments: dp[j] is min cost to emit runs[0..=j].
234        for j in 0..run_count {
235            let mut best_cost = usize::MAX;
236            let mut best_i = j;
237
238            // Optimization: iterate backwards and break if the gap becomes too large.
239            // The gap cost grows linearly, while cursor movement cost is bounded (~10-15 bytes).
240            // Once the gap exceeds ~20 cells, merging is strictly worse than moving.
241            // We use 32 as a conservative safety bound.
242            for i in (0..=j).rev() {
243                let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
244                let total_cells = (row_runs[j].x1 - row_runs[i].x0 + 1) as usize;
245                let gap_cells = total_cells - changed_cells;
246
247                if gap_cells > 32 {
248                    break;
249                }
250
251                let from_x = if i == 0 {
252                    prev_x
253                } else {
254                    Some(row_runs[i - 1].x1.saturating_add(1))
255                };
256                let from_y = if i == 0 { prev_y } else { Some(row_y) };
257
258                let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
259                let gap_overhead = gap_cells * 2; // conservative: char + style amortized
260                let emit_cost = changed_cells + gap_overhead;
261
262                let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
263                let cost = prev_cost
264                    .saturating_add(move_cost)
265                    .saturating_add(emit_cost);
266
267                if cost < best_cost {
268                    best_cost = cost;
269                    best_i = i;
270                }
271            }
272
273            scratch.dp[j] = best_cost;
274            scratch.prev[j] = best_i;
275        }
276
277        // Reconstruct spans from back to front.
278        let mut spans: SmallVec<[RowSpan; 4]> = SmallVec::new();
279        let mut j = run_count - 1;
280        loop {
281            let i = scratch.prev[j];
282            spans.push(RowSpan {
283                y: row_y,
284                x0: row_runs[i].x0,
285                x1: row_runs[j].x1,
286            });
287            if i == 0 {
288                break;
289            }
290            j = i - 1;
291        }
292        spans.reverse();
293
294        RowPlan {
295            spans,
296            total_cost: scratch.dp[run_count - 1],
297        }
298    }
299}
300
301/// Cached style state for comparison.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303struct CellStyle {
304    fg: PackedRgba,
305    bg: PackedRgba,
306    attrs: StyleFlags,
307}
308
309impl Default for CellStyle {
310    fn default() -> Self {
311        Self {
312            fg: PackedRgba::TRANSPARENT,
313            bg: PackedRgba::TRANSPARENT,
314            attrs: StyleFlags::empty(),
315        }
316    }
317}
318impl CellStyle {
319    fn from_cell(cell: &Cell) -> Self {
320        Self {
321            fg: cell.fg,
322            bg: cell.bg,
323            attrs: cell.attrs.flags(),
324        }
325    }
326}
327
328/// State-tracked ANSI presenter.
329///
330/// Transforms buffer diffs into minimal terminal output by tracking
331/// the current terminal state and only emitting necessary escape sequences.
332pub struct Presenter<W: Write> {
333    /// Buffered writer for efficient output, with byte counting.
334    writer: CountingWriter<BufWriter<W>>,
335    /// Current style state (None = unknown/reset).
336    current_style: Option<CellStyle>,
337    /// Current hyperlink ID (None = no link).
338    current_link: Option<u32>,
339    /// Current cursor X position (0-indexed). None = unknown.
340    cursor_x: Option<u16>,
341    /// Current cursor Y position (0-indexed). None = unknown.
342    cursor_y: Option<u16>,
343    /// Viewport Y offset (added to all row coordinates).
344    viewport_offset_y: u16,
345    /// Terminal capabilities for conditional output.
346    capabilities: TerminalCapabilities,
347    /// Reusable scratch buffers for the cost-model DP, avoiding per-row
348    /// heap allocations in the hot presentation path.
349    plan_scratch: cost_model::RowPlanScratch,
350    /// Reusable buffer for change runs, avoiding per-frame allocation.
351    runs_buf: Vec<ChangeRun>,
352}
353
354impl<W: Write> Presenter<W> {
355    /// Create a new presenter with the given writer and capabilities.
356    pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
357        Self {
358            writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
359            current_style: None,
360            current_link: None,
361            cursor_x: None,
362            cursor_y: None,
363            viewport_offset_y: 0,
364            capabilities,
365            plan_scratch: cost_model::RowPlanScratch::default(),
366            runs_buf: Vec::new(),
367        }
368    }
369
370    /// Get mutable access to the innermost writer (`W`).
371    ///
372    /// This allows the caller to write raw data (e.g. logs) bypassing the
373    /// presenter's state tracking. Note that this may invalidate cursor
374    /// tracking if the raw writes move the cursor.
375    pub fn writer_mut(&mut self) -> &mut W {
376        self.writer.inner_mut().get_mut()
377    }
378
379    /// Get mutable access to the full counting writer stack.
380    ///
381    /// This exposes `CountingWriter<BufWriter<W>>` so callers can access
382    /// byte counting, buffered flush, etc.
383    pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
384        &mut self.writer
385    }
386
387    /// Set the viewport Y offset.
388    ///
389    /// All subsequent render operations will add this offset to row coordinates.
390    /// Useful for inline mode where the UI starts at a specific row.
391    pub fn set_viewport_offset_y(&mut self, offset: u16) {
392        self.viewport_offset_y = offset;
393    }
394
395    /// Get the terminal capabilities.
396    #[inline]
397    pub fn capabilities(&self) -> &TerminalCapabilities {
398        &self.capabilities
399    }
400
401    /// Present a frame using the given buffer and diff.
402    ///
403    /// This is the main entry point for rendering. It:
404    /// 1. Begins synchronized output (if supported)
405    /// 2. Emits changes based on the diff
406    /// 3. Resets style and closes links
407    /// 4. Ends synchronized output
408    /// 5. Flushes all buffered output
409    pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
410        self.present_with_pool(buffer, diff, None, None)
411    }
412
413    /// Present a frame with grapheme pool and link registry.
414    pub fn present_with_pool(
415        &mut self,
416        buffer: &Buffer,
417        diff: &BufferDiff,
418        pool: Option<&GraphemePool>,
419        links: Option<&LinkRegistry>,
420    ) -> io::Result<PresentStats> {
421        let bracket_supported = self.capabilities.use_sync_output();
422
423        #[cfg(feature = "tracing")]
424        let _span = tracing::info_span!(
425            "present",
426            width = buffer.width(),
427            height = buffer.height(),
428            changes = diff.len()
429        );
430        #[cfg(feature = "tracing")]
431        let _guard = _span.enter();
432
433        #[cfg(feature = "tracing")]
434        let fallback_used = !bracket_supported;
435        #[cfg(feature = "tracing")]
436        let _sync_span = tracing::info_span!(
437            "render.sync_bracket",
438            bracket_supported,
439            fallback_used,
440            frame_bytes = tracing::field::Empty,
441        );
442        #[cfg(feature = "tracing")]
443        let _sync_guard = _sync_span.enter();
444
445        // Calculate runs upfront for stats, reusing the runs buffer.
446        diff.runs_into(&mut self.runs_buf);
447        let run_count = self.runs_buf.len();
448        let cells_changed = diff.len();
449
450        // Start stats collection
451        self.writer.reset_counter();
452        let collector = StatsCollector::start(cells_changed, run_count);
453
454        // Begin synchronized output to prevent flicker.
455        // When sync brackets are supported, use DEC 2026 for atomic frame display.
456        // Otherwise, fall back to cursor-hiding to reduce visual flicker.
457        if bracket_supported {
458            if let Err(err) = ansi::sync_begin(&mut self.writer) {
459                // Begin writes can fail after partial bytes; best-effort close
460                // avoids leaving the terminal parser in sync-output mode.
461                let _ = ansi::sync_end(&mut self.writer);
462                let _ = self.writer.flush();
463                return Err(err);
464            }
465        } else {
466            #[cfg(feature = "tracing")]
467            tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
468            ansi::cursor_hide(&mut self.writer)?;
469        }
470
471        // Emit diff using run grouping for efficiency.
472        let emit_result = self.emit_diff_runs(buffer, pool, links);
473
474        // Always attempt to restore terminal state, even if diff emission failed.
475        let reset_result = ansi::sgr_reset(&mut self.writer);
476        self.current_style = None;
477
478        let hyperlink_close_result = if self.current_link.is_some() {
479            let res = ansi::hyperlink_end(&mut self.writer);
480            if res.is_ok() {
481                self.current_link = None;
482            }
483            Some(res)
484        } else {
485            None
486        };
487
488        let bracket_end_result = if bracket_supported {
489            ansi::sync_end(&mut self.writer)
490        } else {
491            ansi::cursor_show(&mut self.writer)
492        };
493
494        let flush_result = self.writer.flush();
495
496        // Prioritize terminal-state restoration errors over emission errors:
497        // if cleanup fails (reset/link-close/sync-end/flush), callers need that
498        // failure surfaced immediately to avoid leaving the terminal wedged.
499        let cleanup_error = reset_result
500            .err()
501            .or_else(|| hyperlink_close_result.and_then(Result::err))
502            .or_else(|| bracket_end_result.err())
503            .or_else(|| flush_result.err());
504        if let Some(err) = cleanup_error {
505            return Err(err);
506        }
507        emit_result?;
508
509        let stats = collector.finish(self.writer.bytes_written());
510
511        #[cfg(feature = "tracing")]
512        {
513            _sync_span.record("frame_bytes", stats.bytes_emitted);
514            stats.log();
515            tracing::trace!("frame presented");
516        }
517
518        Ok(stats)
519    }
520
521    /// Emit diff runs using the cost model and internal buffers.
522    ///
523    /// This allows advanced callers (like TerminalWriter) to drive the emission
524    /// phase manually while still benefiting from the optimization logic.
525    /// The caller must populate `self.runs_buf` before calling this (e.g. via `diff.runs_into`).
526    pub fn emit_diff_runs(
527        &mut self,
528        buffer: &Buffer,
529        pool: Option<&GraphemePool>,
530        links: Option<&LinkRegistry>,
531    ) -> io::Result<()> {
532        #[cfg(feature = "tracing")]
533        let _span = tracing::debug_span!("emit_diff");
534        #[cfg(feature = "tracing")]
535        let _guard = _span.enter();
536
537        #[cfg(feature = "tracing")]
538        tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
539
540        // Group runs by row and apply cost model per row
541        let mut i = 0;
542        while i < self.runs_buf.len() {
543            let row_y = self.runs_buf[i].y;
544
545            // Collect all runs on this row
546            let row_start = i;
547            while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
548                i += 1;
549            }
550            let row_runs = &self.runs_buf[row_start..i];
551
552            let plan = cost_model::plan_row_reuse(
553                row_runs,
554                self.cursor_x,
555                self.cursor_y,
556                &mut self.plan_scratch,
557            );
558
559            #[cfg(feature = "tracing")]
560            tracing::trace!(
561                row = row_y,
562                spans = plan.spans().len(),
563                cost = plan.total_cost(),
564                "row plan"
565            );
566
567            let row = buffer.row_cells(row_y);
568            for span in plan.spans() {
569                self.move_cursor_optimal(span.x0, span.y)?;
570                // Hot path: avoid recomputing `y * width + x` for every cell.
571                let start = span.x0 as usize;
572                let end = span.x1 as usize;
573                debug_assert!(start <= end);
574                debug_assert!(end < row.len());
575
576                let mut idx = start;
577                for cell in &row[start..=end] {
578                    self.emit_cell(idx as u16, cell, pool, links)?;
579                    idx += 1;
580                }
581            }
582        }
583        Ok(())
584    }
585
586    /// Prepare the runs buffer from a diff.
587    ///
588    /// Helper for external callers to populate the runs buffer before calling `emit_diff_runs`.
589    pub fn prepare_runs(&mut self, diff: &BufferDiff) {
590        diff.runs_into(&mut self.runs_buf);
591    }
592
593    /// Emit a single cell.
594    fn emit_cell(
595        &mut self,
596        x: u16,
597        cell: &Cell,
598        pool: Option<&GraphemePool>,
599        links: Option<&LinkRegistry>,
600    ) -> io::Result<()> {
601        // Continuation cells are the tail cells of wide glyphs. Emitting the
602        // head glyph already advanced the terminal cursor by the full width, so
603        // we normally skip emitting these cells.
604        //
605        // If we ever start emitting at a continuation cell (e.g. a run begins
606        // mid-wide-character), we must still advance the terminal cursor by one
607        // cell to keep subsequent emissions aligned. Prefer CUF over writing a
608        // space so we don't overwrite a valid wide-glyph tail.
609        if cell.is_continuation() {
610            match self.cursor_x {
611                // Cursor already advanced past this cell by a previously-emitted wide head.
612                Some(cx) if cx > x => return Ok(()),
613                // Cursor is positioned at (or before) this continuation cell: advance by 1.
614                Some(cx) => {
615                    ansi::cuf(&mut self.writer, 1)?;
616                    self.cursor_x = Some(cx.saturating_add(1));
617                    return Ok(());
618                }
619                // Defensive: move_cursor_optimal should always set cursor_x before emit_cell is called.
620                None => {
621                    ansi::cuf(&mut self.writer, 1)?;
622                    self.cursor_x = Some(x.saturating_add(1));
623                    return Ok(());
624                }
625            }
626        }
627
628        // Emit style changes if needed
629        self.emit_style_changes(cell)?;
630
631        // Emit link changes if needed
632        self.emit_link_changes(cell, links)?;
633
634        // Calculate effective width and check for zero-width content (e.g. combining marks)
635        // stored as standalone cells. These must be replaced to maintain grid alignment.
636        let raw_width = cell.content.width();
637        let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
638
639        if is_zero_width_content {
640            // Replace with U+FFFD Replacement Character (width 1)
641            self.writer.write_all(b"\xEF\xBF\xBD")?;
642        } else {
643            // Emit normal content
644            self.emit_content(cell, pool)?;
645        }
646
647        // Update cursor position (character output advances cursor)
648        if let Some(cx) = self.cursor_x {
649            // Empty cells are emitted as spaces (width 1).
650            // Zero-width content replaced by U+FFFD is width 1.
651            let width = if cell.is_empty() || is_zero_width_content {
652                1
653            } else {
654                raw_width
655            };
656            self.cursor_x = Some(cx.saturating_add(width as u16));
657        }
658
659        Ok(())
660    }
661
662    /// Emit style changes if the cell style differs from current.
663    ///
664    /// Uses SGR delta: instead of resetting and re-applying all style properties,
665    /// we compute the minimal set of changes needed (fg delta, bg delta, attr
666    /// toggles). Falls back to reset+apply only when a full reset would be cheaper.
667    fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
668        let new_style = CellStyle::from_cell(cell);
669
670        // Check if style changed
671        if self.current_style == Some(new_style) {
672            return Ok(());
673        }
674
675        match self.current_style {
676            None => {
677                // No known state - must do full apply (but skip reset if we haven't
678                // emitted anything yet, the frame-start reset handles that).
679                self.emit_style_full(new_style)?;
680            }
681            Some(old_style) => {
682                self.emit_style_delta(old_style, new_style)?;
683            }
684        }
685
686        self.current_style = Some(new_style);
687        Ok(())
688    }
689
690    /// Full style apply (reset + set all properties). Used when previous state is unknown.
691    fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
692        ansi::sgr_reset(&mut self.writer)?;
693        if style.fg.a() > 0 {
694            ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
695        }
696        if style.bg.a() > 0 {
697            ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
698        }
699        if !style.attrs.is_empty() {
700            ansi::sgr_flags(&mut self.writer, style.attrs)?;
701        }
702        Ok(())
703    }
704
705    #[inline]
706    fn dec_len_u8(value: u8) -> u32 {
707        if value >= 100 {
708            3
709        } else if value >= 10 {
710            2
711        } else {
712            1
713        }
714    }
715
716    #[inline]
717    fn sgr_code_len(code: u8) -> u32 {
718        2 + Self::dec_len_u8(code) + 1
719    }
720
721    #[inline]
722    fn sgr_flags_len(flags: StyleFlags) -> u32 {
723        if flags.is_empty() {
724            return 0;
725        }
726        let mut count = 0u32;
727        let mut digits = 0u32;
728        for (flag, codes) in ansi::FLAG_TABLE {
729            if flags.contains(flag) {
730                count += 1;
731                digits += Self::dec_len_u8(codes.on);
732            }
733        }
734        if count == 0 {
735            return 0;
736        }
737        3 + digits + (count - 1)
738    }
739
740    #[inline]
741    fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
742        if flags.is_empty() {
743            return 0;
744        }
745        let mut len = 0u32;
746        for (flag, codes) in ansi::FLAG_TABLE {
747            if flags.contains(flag) {
748                len += Self::sgr_code_len(codes.off);
749            }
750        }
751        len
752    }
753
754    #[inline]
755    fn sgr_rgb_len(color: PackedRgba) -> u32 {
756        10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
757    }
758
759    /// Emit minimal SGR delta between old and new styles.
760    ///
761    /// Computes which properties changed and emits only those.
762    /// Falls back to reset+apply when that would produce fewer bytes.
763    fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
764        let attrs_removed = old.attrs & !new.attrs;
765        let attrs_added = new.attrs & !old.attrs;
766        let fg_changed = old.fg != new.fg;
767        let bg_changed = old.bg != new.bg;
768
769        // Hot path for VFX-style workloads: attributes are unchanged and only
770        // colors vary. In this case, delta emission is always no worse than a
771        // reset+reapply baseline, so skip cost estimation and flag diff logic.
772        if old.attrs == new.attrs {
773            if fg_changed {
774                ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
775            }
776            if bg_changed {
777                ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
778            }
779            return Ok(());
780        }
781
782        let mut collateral = StyleFlags::empty();
783        if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
784            collateral |= StyleFlags::DIM;
785        }
786        if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
787            collateral |= StyleFlags::BOLD;
788        }
789
790        let mut delta_len = 0u32;
791        delta_len += Self::sgr_flags_off_len(attrs_removed);
792        delta_len += Self::sgr_flags_len(collateral);
793        delta_len += Self::sgr_flags_len(attrs_added);
794        if fg_changed {
795            delta_len += if new.fg.a() == 0 {
796                5
797            } else {
798                Self::sgr_rgb_len(new.fg)
799            };
800        }
801        if bg_changed {
802            delta_len += if new.bg.a() == 0 {
803                5
804            } else {
805                Self::sgr_rgb_len(new.bg)
806            };
807        }
808
809        let mut baseline_len = 4u32;
810        if new.fg.a() > 0 {
811            baseline_len += Self::sgr_rgb_len(new.fg);
812        }
813        if new.bg.a() > 0 {
814            baseline_len += Self::sgr_rgb_len(new.bg);
815        }
816        baseline_len += Self::sgr_flags_len(new.attrs);
817
818        if delta_len > baseline_len {
819            return self.emit_style_full(new);
820        }
821
822        // Handle attr removal: emit individual off codes
823        if !attrs_removed.is_empty() {
824            let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
825            // Re-enable any collaterally disabled flags
826            if !collateral.is_empty() {
827                ansi::sgr_flags(&mut self.writer, collateral)?;
828            }
829        }
830
831        // Handle attr addition: emit on codes for newly added flags
832        if !attrs_added.is_empty() {
833            ansi::sgr_flags(&mut self.writer, attrs_added)?;
834        }
835
836        // Handle fg color change
837        if fg_changed {
838            ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
839        }
840
841        // Handle bg color change
842        if bg_changed {
843            ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
844        }
845
846        Ok(())
847    }
848
849    /// Emit hyperlink changes if the cell link differs from current.
850    fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
851        // Respect capability policy so callers running in mux contexts don't
852        // emit OSC 8 sequences even if the raw capability flag is set.
853        if !self.capabilities.use_hyperlinks() {
854            if self.current_link.is_some() {
855                ansi::hyperlink_end(&mut self.writer)?;
856            }
857            self.current_link = None;
858            return Ok(());
859        }
860
861        let raw_link_id = cell.attrs.link_id();
862        let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
863            None
864        } else {
865            Some(raw_link_id)
866        };
867
868        // Check if link changed
869        if self.current_link == new_link {
870            return Ok(());
871        }
872
873        // Close current link if open
874        if self.current_link.is_some() {
875            ansi::hyperlink_end(&mut self.writer)?;
876        }
877
878        // Open new link if present and resolvable
879        let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
880            && let Some(url) = registry.get(link_id)
881            && is_safe_hyperlink_url(url)
882        {
883            ansi::hyperlink_start(&mut self.writer, url)?;
884            true
885        } else {
886            false
887        };
888
889        // Only track as current if we actually opened it
890        self.current_link = if actually_opened { new_link } else { None };
891        Ok(())
892    }
893
894    /// Emit cell content (character or grapheme).
895    fn emit_content(&mut self, cell: &Cell, pool: Option<&GraphemePool>) -> io::Result<()> {
896        // Check if this is a grapheme reference
897        if let Some(grapheme_id) = cell.content.grapheme_id() {
898            if let Some(pool) = pool
899                && let Some(text) = pool.get(grapheme_id)
900            {
901                let safe = sanitize(text);
902                if !safe.is_empty() {
903                    return self.writer.write_all(safe.as_bytes());
904                }
905            }
906            // Fallback: emit replacement characters matching expected width
907            // to maintain cursor synchronization.
908            let width = cell.content.width();
909            if width > 0 {
910                for _ in 0..width {
911                    self.writer.write_all(b"?")?;
912                }
913            }
914            return Ok(());
915        }
916
917        // Regular character content
918        if let Some(ch) = cell.content.as_char() {
919            // Sanitize control characters that would break the grid.
920            let safe_ch = if ch.is_control() { ' ' } else { ch };
921            let mut buf = [0u8; 4];
922            let encoded = safe_ch.encode_utf8(&mut buf);
923            self.writer.write_all(encoded.as_bytes())
924        } else {
925            // Empty cell - emit space
926            self.writer.write_all(b" ")
927        }
928    }
929
930    /// Move cursor to the specified position.
931    fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
932        // Skip if already at position
933        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
934            return Ok(());
935        }
936
937        // Use CUP (cursor position) for absolute positioning
938        ansi::cup(&mut self.writer, y, x)?;
939        self.cursor_x = Some(x);
940        self.cursor_y = Some(y);
941        Ok(())
942    }
943
944    /// Move cursor using the cheapest available operation.
945    ///
946    /// Compares CUP (absolute), CHA (column-only), and CUF (relative forward)
947    /// to select the minimum-cost cursor movement.
948    fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
949        // Skip if already at position
950        if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
951            return Ok(());
952        }
953
954        // Decide cheapest move
955        let same_row = self.cursor_y == Some(y);
956        let forward = same_row && self.cursor_x.is_some_and(|cx| x > cx);
957
958        if same_row && forward {
959            let dx = x - self.cursor_x.expect("cursor_x guaranteed by forward check");
960            let cuf = cost_model::cuf_cost(dx);
961            let cha = cost_model::cha_cost(x);
962            let cup = cost_model::cup_cost(y, x);
963
964            if cuf <= cha && cuf <= cup {
965                ansi::cuf(&mut self.writer, dx)?;
966            } else if cha <= cup {
967                ansi::cha(&mut self.writer, x)?;
968            } else {
969                ansi::cup(&mut self.writer, y, x)?;
970            }
971        } else if same_row {
972            // Same row, backward or same column
973            let cha = cost_model::cha_cost(x);
974            let cup = cost_model::cup_cost(y, x);
975            if cha <= cup {
976                ansi::cha(&mut self.writer, x)?;
977            } else {
978                ansi::cup(&mut self.writer, y, x)?;
979            }
980        } else {
981            // Different row: CUP is the only option
982            ansi::cup(&mut self.writer, y, x)?;
983        }
984
985        self.cursor_x = Some(x);
986        self.cursor_y = Some(y);
987        Ok(())
988    }
989
990    /// Clear the entire screen.
991    pub fn clear_screen(&mut self) -> io::Result<()> {
992        ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
993        ansi::cup(&mut self.writer, 0, 0)?;
994        self.cursor_x = Some(0);
995        self.cursor_y = Some(0);
996        self.writer.flush()
997    }
998
999    /// Clear a single line.
1000    pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1001        self.move_cursor_to(0, y)?;
1002        ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1003        self.writer.flush()
1004    }
1005
1006    /// Hide the cursor.
1007    pub fn hide_cursor(&mut self) -> io::Result<()> {
1008        ansi::cursor_hide(&mut self.writer)?;
1009        self.writer.flush()
1010    }
1011
1012    /// Show the cursor.
1013    pub fn show_cursor(&mut self) -> io::Result<()> {
1014        ansi::cursor_show(&mut self.writer)?;
1015        self.writer.flush()
1016    }
1017
1018    /// Position the cursor at the specified coordinates.
1019    pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1020        self.move_cursor_to(x, y)?;
1021        self.writer.flush()
1022    }
1023
1024    /// Reset the presenter state.
1025    ///
1026    /// Useful after resize or when terminal state is unknown.
1027    pub fn reset(&mut self) {
1028        self.current_style = None;
1029        self.current_link = None;
1030        self.cursor_x = None;
1031        self.cursor_y = None;
1032    }
1033
1034    /// Flush any buffered output.
1035    pub fn flush(&mut self) -> io::Result<()> {
1036        self.writer.flush()
1037    }
1038
1039    /// Get the inner writer (consuming the presenter).
1040    ///
1041    /// Flushes any buffered data before returning the writer.
1042    pub fn into_inner(self) -> Result<W, io::Error> {
1043        self.writer
1044            .into_inner() // CountingWriter -> BufWriter<W>
1045            .into_inner() // BufWriter<W> -> Result<W, IntoInnerError>
1046            .map_err(|e| e.into_error())
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use crate::cell::{CellAttrs, CellContent};
1054    use crate::link_registry::LinkRegistry;
1055
1056    fn test_presenter() -> Presenter<Vec<u8>> {
1057        let caps = TerminalCapabilities::basic();
1058        Presenter::new(Vec::new(), caps)
1059    }
1060
1061    fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1062        let mut caps = TerminalCapabilities::basic();
1063        caps.sync_output = true;
1064        Presenter::new(Vec::new(), caps)
1065    }
1066
1067    fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1068        let mut caps = TerminalCapabilities::basic();
1069        caps.osc8_hyperlinks = true;
1070        Presenter::new(Vec::new(), caps)
1071    }
1072
1073    fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1074        presenter.into_inner().unwrap()
1075    }
1076
1077    fn legacy_plan_row(
1078        row_runs: &[ChangeRun],
1079        prev_x: Option<u16>,
1080        prev_y: Option<u16>,
1081    ) -> Vec<cost_model::RowSpan> {
1082        if row_runs.is_empty() {
1083            return Vec::new();
1084        }
1085
1086        if row_runs.len() == 1 {
1087            let run = row_runs[0];
1088            return vec![cost_model::RowSpan {
1089                y: run.y,
1090                x0: run.x0,
1091                x1: run.x1,
1092            }];
1093        }
1094
1095        let row_y = row_runs[0].y;
1096        let first_x = row_runs[0].x0;
1097        let last_x = row_runs[row_runs.len() - 1].x1;
1098
1099        // Estimate sparse cost: sum of move + content for each run
1100        let mut sparse_cost: usize = 0;
1101        let mut cursor_x = prev_x;
1102        let mut cursor_y = prev_y;
1103
1104        for run in row_runs {
1105            let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1106            let cells = (run.x1 - run.x0 + 1) as usize;
1107            sparse_cost += move_cost + cells;
1108            cursor_x = Some(run.x1.saturating_add(1));
1109            cursor_y = Some(row_y);
1110        }
1111
1112        // Estimate merged cost: one move + all cells from first to last
1113        let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1114        let total_cells = (last_x - first_x + 1) as usize;
1115        let changed_cells: usize = row_runs.iter().map(|r| (r.x1 - r.x0 + 1) as usize).sum();
1116        let gap_cells = total_cells - changed_cells;
1117        let gap_overhead = gap_cells * 2;
1118        let merged_cost = merge_move + changed_cells + gap_overhead;
1119
1120        if merged_cost < sparse_cost {
1121            vec![cost_model::RowSpan {
1122                y: row_y,
1123                x0: first_x,
1124                x1: last_x,
1125            }]
1126        } else {
1127            row_runs
1128                .iter()
1129                .map(|run| cost_model::RowSpan {
1130                    y: run.y,
1131                    x0: run.x0,
1132                    x1: run.x1,
1133                })
1134                .collect()
1135        }
1136    }
1137
1138    fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1139        let mut presenter = test_presenter();
1140
1141        for span in spans {
1142            presenter
1143                .move_cursor_optimal(span.x0, span.y)
1144                .expect("cursor move should succeed");
1145            for x in span.x0..=span.x1 {
1146                let cell = buffer.get_unchecked(x, span.y);
1147                presenter
1148                    .emit_cell(x, cell, None, None)
1149                    .expect("emit_cell should succeed");
1150            }
1151        }
1152
1153        presenter
1154            .writer
1155            .write_all(b"\x1b[0m")
1156            .expect("reset should succeed");
1157
1158        presenter.into_inner().expect("presenter output")
1159    }
1160
1161    #[test]
1162    fn empty_diff_produces_minimal_output() {
1163        let mut presenter = test_presenter();
1164        let buffer = Buffer::new(10, 10);
1165        let diff = BufferDiff::new();
1166
1167        presenter.present(&buffer, &diff).unwrap();
1168        let output = get_output(presenter);
1169
1170        // Without sync, fallback hides cursor first, then SGR reset, then cursor show
1171        assert!(output.starts_with(ansi::CURSOR_HIDE));
1172        assert!(output.ends_with(ansi::CURSOR_SHOW));
1173        // SGR reset is still present between the cursor brackets
1174        assert!(
1175            output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1176            "SGR reset should be present"
1177        );
1178    }
1179
1180    #[test]
1181    fn sync_output_wraps_frame() {
1182        let mut presenter = test_presenter_with_sync();
1183        let mut buffer = Buffer::new(3, 1);
1184        buffer.set_raw(0, 0, Cell::from_char('X'));
1185
1186        let old = Buffer::new(3, 1);
1187        let diff = BufferDiff::compute(&old, &buffer);
1188
1189        presenter.present(&buffer, &diff).unwrap();
1190        let output = get_output(presenter);
1191
1192        assert!(
1193            output.starts_with(ansi::SYNC_BEGIN),
1194            "sync output should begin with DEC 2026 begin"
1195        );
1196        assert!(
1197            output.ends_with(ansi::SYNC_END),
1198            "sync output should end with DEC 2026 end"
1199        );
1200    }
1201
1202    #[test]
1203    fn sync_output_obeys_mux_policy() {
1204        let caps = TerminalCapabilities::builder()
1205            .sync_output(true)
1206            .in_tmux(true)
1207            .build();
1208        let mut presenter = Presenter::new(Vec::new(), caps);
1209
1210        let mut buffer = Buffer::new(2, 1);
1211        buffer.set_raw(0, 0, Cell::from_char('X'));
1212        let old = Buffer::new(2, 1);
1213        let diff = BufferDiff::compute(&old, &buffer);
1214
1215        presenter.present(&buffer, &diff).unwrap();
1216        let output = get_output(presenter);
1217
1218        assert!(
1219            !output
1220                .windows(ansi::SYNC_BEGIN.len())
1221                .any(|w| w == ansi::SYNC_BEGIN),
1222            "tmux policy should suppress sync begin"
1223        );
1224        assert!(
1225            !output
1226                .windows(ansi::SYNC_END.len())
1227                .any(|w| w == ansi::SYNC_END),
1228            "tmux policy should suppress sync end"
1229        );
1230    }
1231
1232    #[test]
1233    fn hyperlink_sequences_emitted_and_closed() {
1234        let mut presenter = test_presenter_with_hyperlinks();
1235        let mut buffer = Buffer::new(3, 1);
1236
1237        let mut registry = LinkRegistry::new();
1238        let link_id = registry.register("https://example.com");
1239        let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1240        buffer.set_raw(0, 0, linked);
1241
1242        let old = Buffer::new(3, 1);
1243        let diff = BufferDiff::compute(&old, &buffer);
1244
1245        presenter
1246            .present_with_pool(&buffer, &diff, None, Some(&registry))
1247            .unwrap();
1248        let output = get_output(presenter);
1249
1250        let start = b"\x1b]8;;https://example.com\x1b\\";
1251        let end = b"\x1b]8;;\x1b\\";
1252
1253        let start_pos = output
1254            .windows(start.len())
1255            .position(|w| w == start)
1256            .expect("hyperlink start not found");
1257        let end_pos = output
1258            .windows(end.len())
1259            .position(|w| w == end)
1260            .expect("hyperlink end not found");
1261        let char_pos = output
1262            .iter()
1263            .position(|&b| b == b'L')
1264            .expect("linked character not found");
1265
1266        assert!(start_pos < char_pos, "link start should precede text");
1267        assert!(char_pos < end_pos, "link end should follow text");
1268    }
1269
1270    #[test]
1271    fn single_cell_change() {
1272        let mut presenter = test_presenter();
1273        let mut buffer = Buffer::new(10, 10);
1274        buffer.set_raw(5, 5, Cell::from_char('X'));
1275
1276        let old = Buffer::new(10, 10);
1277        let diff = BufferDiff::compute(&old, &buffer);
1278
1279        presenter.present(&buffer, &diff).unwrap();
1280        let output = get_output(presenter);
1281
1282        // Should contain cursor position and character
1283        let output_str = String::from_utf8_lossy(&output);
1284        assert!(output_str.contains("X"));
1285        assert!(output_str.contains("\x1b[")); // Contains escape sequences
1286    }
1287
1288    #[test]
1289    fn style_tracking_avoids_redundant_sgr() {
1290        let mut presenter = test_presenter();
1291        let mut buffer = Buffer::new(10, 1);
1292
1293        // Set multiple cells with same style
1294        let fg = PackedRgba::rgb(255, 0, 0);
1295        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1296        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1297        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1298
1299        let old = Buffer::new(10, 1);
1300        let diff = BufferDiff::compute(&old, &buffer);
1301
1302        presenter.present(&buffer, &diff).unwrap();
1303        let output = get_output(presenter);
1304
1305        // Count SGR sequences (should be minimal due to style tracking)
1306        let output_str = String::from_utf8_lossy(&output);
1307        let sgr_count = output_str.matches("\x1b[38;2").count();
1308        // Should have exactly 1 fg color sequence (style set once, reused for ABC)
1309        assert_eq!(
1310            sgr_count, 1,
1311            "Expected 1 SGR fg sequence, got {}",
1312            sgr_count
1313        );
1314    }
1315
1316    #[test]
1317    fn reset_reapplies_style_after_clear() {
1318        let mut presenter = test_presenter();
1319        let mut buffer = Buffer::new(1, 1);
1320        let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1321        buffer.set_raw(0, 0, styled);
1322
1323        let old = Buffer::new(1, 1);
1324        let diff = BufferDiff::compute(&old, &buffer);
1325
1326        presenter.present(&buffer, &diff).unwrap();
1327        presenter.reset();
1328        presenter.present(&buffer, &diff).unwrap();
1329
1330        let output = get_output(presenter);
1331        let output_str = String::from_utf8_lossy(&output);
1332        let sgr_count = output_str.matches("\x1b[38;2").count();
1333
1334        assert_eq!(
1335            sgr_count, 2,
1336            "Expected style to be re-applied after reset, got {sgr_count} sequences"
1337        );
1338    }
1339
1340    #[test]
1341    fn cursor_position_optimized() {
1342        let mut presenter = test_presenter();
1343        let mut buffer = Buffer::new(10, 5);
1344
1345        // Set adjacent cells (should be one run)
1346        buffer.set_raw(3, 2, Cell::from_char('A'));
1347        buffer.set_raw(4, 2, Cell::from_char('B'));
1348        buffer.set_raw(5, 2, Cell::from_char('C'));
1349
1350        let old = Buffer::new(10, 5);
1351        let diff = BufferDiff::compute(&old, &buffer);
1352
1353        presenter.present(&buffer, &diff).unwrap();
1354        let output = get_output(presenter);
1355
1356        // Should have only one CUP sequence for the run
1357        let output_str = String::from_utf8_lossy(&output);
1358        let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1359
1360        // Content should be "ABC" somewhere in output
1361        assert!(
1362            output_str.contains("ABC")
1363                || (output_str.contains('A')
1364                    && output_str.contains('B')
1365                    && output_str.contains('C'))
1366        );
1367    }
1368
1369    #[test]
1370    fn sync_output_wrapped_when_supported() {
1371        let mut presenter = test_presenter_with_sync();
1372        let buffer = Buffer::new(10, 10);
1373        let diff = BufferDiff::new();
1374
1375        presenter.present(&buffer, &diff).unwrap();
1376        let output = get_output(presenter);
1377
1378        // Should have sync begin and end
1379        assert!(output.starts_with(ansi::SYNC_BEGIN));
1380        assert!(
1381            output
1382                .windows(ansi::SYNC_END.len())
1383                .any(|w| w == ansi::SYNC_END)
1384        );
1385    }
1386
1387    #[test]
1388    fn clear_screen_works() {
1389        let mut presenter = test_presenter();
1390        presenter.clear_screen().unwrap();
1391        let output = get_output(presenter);
1392
1393        // Should contain erase display sequence
1394        assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1395    }
1396
1397    #[test]
1398    fn cursor_visibility() {
1399        let mut presenter = test_presenter();
1400
1401        presenter.hide_cursor().unwrap();
1402        presenter.show_cursor().unwrap();
1403
1404        let output = get_output(presenter);
1405        let output_str = String::from_utf8_lossy(&output);
1406
1407        assert!(output_str.contains("\x1b[?25l")); // Hide
1408        assert!(output_str.contains("\x1b[?25h")); // Show
1409    }
1410
1411    #[test]
1412    fn reset_clears_state() {
1413        let mut presenter = test_presenter();
1414        presenter.cursor_x = Some(50);
1415        presenter.cursor_y = Some(20);
1416        presenter.current_style = Some(CellStyle::default());
1417
1418        presenter.reset();
1419
1420        assert!(presenter.cursor_x.is_none());
1421        assert!(presenter.cursor_y.is_none());
1422        assert!(presenter.current_style.is_none());
1423    }
1424
1425    #[test]
1426    fn position_cursor() {
1427        let mut presenter = test_presenter();
1428        presenter.position_cursor(10, 5).unwrap();
1429
1430        let output = get_output(presenter);
1431        // CUP is 1-indexed: row 6, col 11
1432        assert!(
1433            output
1434                .windows(b"\x1b[6;11H".len())
1435                .any(|w| w == b"\x1b[6;11H")
1436        );
1437    }
1438
1439    #[test]
1440    fn skip_cursor_move_when_already_at_position() {
1441        let mut presenter = test_presenter();
1442        presenter.cursor_x = Some(5);
1443        presenter.cursor_y = Some(3);
1444
1445        // Move to same position
1446        presenter.move_cursor_to(5, 3).unwrap();
1447
1448        // Should produce no output
1449        let output = get_output(presenter);
1450        assert!(output.is_empty());
1451    }
1452
1453    #[test]
1454    fn continuation_cells_skipped() {
1455        let mut presenter = test_presenter();
1456        let mut buffer = Buffer::new(10, 1);
1457
1458        // Set a wide character
1459        buffer.set_raw(0, 0, Cell::from_char('中'));
1460        // The next cell would be a continuation - simulate it
1461        buffer.set_raw(1, 0, Cell::CONTINUATION);
1462
1463        // Create a diff that includes both cells
1464        let old = Buffer::new(10, 1);
1465        let diff = BufferDiff::compute(&old, &buffer);
1466
1467        presenter.present(&buffer, &diff).unwrap();
1468        let output = get_output(presenter);
1469
1470        // Should contain the wide character
1471        let output_str = String::from_utf8_lossy(&output);
1472        assert!(output_str.contains('中'));
1473    }
1474
1475    #[test]
1476    fn continuation_at_run_start_advances_cursor_without_overwriting() {
1477        let mut presenter = test_presenter();
1478        let mut old = Buffer::new(3, 1);
1479        let mut new = Buffer::new(3, 1);
1480
1481        // Construct an inconsistent old/new pair that forces a diff which begins at a
1482        // continuation cell. This simulates starting emission mid-wide-character.
1483        //
1484        // In this case, the presenter must advance the cursor by one cell, but must
1485        // not overwrite the cell with a space (which can clobber a valid wide glyph tail).
1486        old.set_raw(0, 0, Cell::from_char('中'));
1487        new.set_raw(0, 0, Cell::from_char('中'));
1488        old.set_raw(1, 0, Cell::from_char('X'));
1489        new.set_raw(1, 0, Cell::CONTINUATION);
1490
1491        let diff = BufferDiff::compute(&old, &new);
1492        assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1493
1494        presenter.present(&new, &diff).unwrap();
1495        let output = get_output(presenter);
1496
1497        // Advance should be done via CUF (\x1b[C), not by emitting a space.
1498        assert!(output.windows(3).any(|w| w == b"\x1b[C"));
1499        assert!(
1500            !output.contains(&b' '),
1501            "should not write a space when advancing over a continuation cell"
1502        );
1503    }
1504
1505    #[test]
1506    fn wide_char_missing_continuation_causes_drift() {
1507        let mut presenter = test_presenter();
1508        let mut buffer = Buffer::new(10, 1);
1509
1510        // Bug scenario: User sets wide char but forgets continuation
1511        buffer.set_raw(0, 0, Cell::from_char('中'));
1512        // (1,0) remains empty (space)
1513
1514        let old = Buffer::new(10, 1);
1515        let diff = BufferDiff::compute(&old, &buffer);
1516
1517        presenter.present(&buffer, &diff).unwrap();
1518        let output = get_output(presenter);
1519        let _output_str = String::from_utf8_lossy(&output);
1520
1521        // Expected if broken: '中' (width 2) followed by ' ' (width 1)
1522        // '中' takes x=0,1 on screen. Cursor moves to 2.
1523        // Loop visits x=1 (empty). Emits ' '. Cursor moves to 3.
1524        // So we emitted 3 columns worth of stuff for 2 cells of buffer.
1525
1526        // This is hard to assert on the raw string without parsing ANSI,
1527        // but we know '中' is bytes e4 b8 ad.
1528
1529        // If correct (with continuation):
1530        // x=0: emits '中'. cursor -> 2.
1531        // x=1: skipped (continuation).
1532        // x=2: next char...
1533
1534        // If incorrect (current behavior):
1535        // x=0: emits '中'. cursor -> 2.
1536        // x=1: emits ' '. cursor -> 3.
1537
1538        // We can check if a space is emitted immediately after the wide char.
1539        // Note: Presenter might optimize cursor movement, but here we are writing sequentially.
1540
1541        // The output should contain '中' then ' '.
1542        // In a correct world, x=1 is CONTINUATION, so ' ' is NOT emitted for x=1.
1543
1544        // So if we see '中' followed immediately by ' ' (or escape sequence then ' '), it implies drift IF x=1 was supposed to be covered by '中'.
1545
1546        // To verify this failure, we assert that the output DOES contain the space.
1547        // If we fix the bug in Buffer::set, this test setup would need to use set() instead of set_raw()
1548        // to prove the fix.
1549
1550        // But for now, let's just assert the current broken behavior exists?
1551        // No, I want to assert the *bug* is that the buffer allows this state.
1552        // The Presenter is doing its job (GIGO).
1553
1554        // Let's rely on the fix verification instead.
1555    }
1556
1557    #[test]
1558    fn hyperlink_emitted_with_registry() {
1559        let mut presenter = test_presenter_with_hyperlinks();
1560        let mut buffer = Buffer::new(10, 1);
1561        let mut links = LinkRegistry::new();
1562
1563        let link_id = links.register("https://example.com");
1564        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1565        buffer.set_raw(0, 0, cell);
1566
1567        let old = Buffer::new(10, 1);
1568        let diff = BufferDiff::compute(&old, &buffer);
1569
1570        presenter
1571            .present_with_pool(&buffer, &diff, None, Some(&links))
1572            .unwrap();
1573        let output = get_output(presenter);
1574        let output_str = String::from_utf8_lossy(&output);
1575
1576        // OSC 8 open with URL
1577        assert!(
1578            output_str.contains("\x1b]8;;https://example.com\x1b\\"),
1579            "Expected OSC 8 open, got: {:?}",
1580            output_str
1581        );
1582        // OSC 8 close (empty URL)
1583        assert!(
1584            output_str.contains("\x1b]8;;\x1b\\"),
1585            "Expected OSC 8 close, got: {:?}",
1586            output_str
1587        );
1588    }
1589
1590    #[test]
1591    fn hyperlink_not_emitted_without_registry() {
1592        let mut presenter = test_presenter_with_hyperlinks();
1593        let mut buffer = Buffer::new(10, 1);
1594
1595        // Set a link ID without providing a registry
1596        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1597        buffer.set_raw(0, 0, cell);
1598
1599        let old = Buffer::new(10, 1);
1600        let diff = BufferDiff::compute(&old, &buffer);
1601
1602        // Present without link registry
1603        presenter.present(&buffer, &diff).unwrap();
1604        let output = get_output(presenter);
1605        let output_str = String::from_utf8_lossy(&output);
1606
1607        // No OSC 8 sequences should appear
1608        assert!(
1609            !output_str.contains("\x1b]8;"),
1610            "OSC 8 should not appear without registry, got: {:?}",
1611            output_str
1612        );
1613    }
1614
1615    #[test]
1616    fn hyperlink_not_emitted_for_unknown_id() {
1617        let mut presenter = test_presenter_with_hyperlinks();
1618        let mut buffer = Buffer::new(10, 1);
1619        let links = LinkRegistry::new();
1620
1621        let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1622        buffer.set_raw(0, 0, cell);
1623
1624        let old = Buffer::new(10, 1);
1625        let diff = BufferDiff::compute(&old, &buffer);
1626
1627        presenter
1628            .present_with_pool(&buffer, &diff, None, Some(&links))
1629            .unwrap();
1630        let output = get_output(presenter);
1631        let output_str = String::from_utf8_lossy(&output);
1632
1633        assert!(
1634            !output_str.contains("\x1b]8;"),
1635            "OSC 8 should not appear for unknown link IDs, got: {:?}",
1636            output_str
1637        );
1638        assert!(output_str.contains('L'));
1639    }
1640
1641    #[test]
1642    fn hyperlink_closed_at_frame_end() {
1643        let mut presenter = test_presenter_with_hyperlinks();
1644        let mut buffer = Buffer::new(10, 1);
1645        let mut links = LinkRegistry::new();
1646
1647        let link_id = links.register("https://example.com");
1648        // Set all cells with the same link
1649        for x in 0..5 {
1650            buffer.set_raw(
1651                x,
1652                0,
1653                Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1654            );
1655        }
1656
1657        let old = Buffer::new(10, 1);
1658        let diff = BufferDiff::compute(&old, &buffer);
1659
1660        presenter
1661            .present_with_pool(&buffer, &diff, None, Some(&links))
1662            .unwrap();
1663        let output = get_output(presenter);
1664
1665        // The close sequence should appear (frame end cleanup)
1666        let close_seq = b"\x1b]8;;\x1b\\";
1667        assert!(
1668            output.windows(close_seq.len()).any(|w| w == close_seq),
1669            "Link must be closed at frame end"
1670        );
1671    }
1672
1673    #[test]
1674    fn hyperlink_transitions_between_links() {
1675        let mut presenter = test_presenter_with_hyperlinks();
1676        let mut buffer = Buffer::new(10, 1);
1677        let mut links = LinkRegistry::new();
1678
1679        let link_a = links.register("https://a.com");
1680        let link_b = links.register("https://b.com");
1681
1682        buffer.set_raw(
1683            0,
1684            0,
1685            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1686        );
1687        buffer.set_raw(
1688            1,
1689            0,
1690            Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1691        );
1692        buffer.set_raw(2, 0, Cell::from_char('C')); // no link
1693
1694        let old = Buffer::new(10, 1);
1695        let diff = BufferDiff::compute(&old, &buffer);
1696
1697        presenter
1698            .present_with_pool(&buffer, &diff, None, Some(&links))
1699            .unwrap();
1700        let output = get_output(presenter);
1701        let output_str = String::from_utf8_lossy(&output);
1702
1703        // Both links should appear
1704        assert!(output_str.contains("https://a.com"));
1705        assert!(output_str.contains("https://b.com"));
1706
1707        // Close sequence must appear at least once (transition or frame end)
1708        let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1709        assert!(
1710            close_count >= 2,
1711            "Expected at least 2 link close sequences (transition + frame end), got {}",
1712            close_count
1713        );
1714    }
1715
1716    #[test]
1717    fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
1718        let caps = TerminalCapabilities::builder()
1719            .osc8_hyperlinks(true)
1720            .in_tmux(true)
1721            .build();
1722        let mut presenter = Presenter::new(Vec::new(), caps);
1723        let mut buffer = Buffer::new(3, 1);
1724        let mut links = LinkRegistry::new();
1725        let link_id = links.register("https://example.com");
1726        buffer.set_raw(
1727            0,
1728            0,
1729            Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1730        );
1731
1732        let old = Buffer::new(3, 1);
1733        let diff = BufferDiff::compute(&old, &buffer);
1734        presenter
1735            .present_with_pool(&buffer, &diff, None, Some(&links))
1736            .unwrap();
1737
1738        let output = get_output(presenter);
1739        let output_str = String::from_utf8_lossy(&output);
1740        assert!(
1741            !output_str.contains("\x1b]8;"),
1742            "tmux policy should suppress OSC 8 sequences"
1743        );
1744        assert!(output_str.contains('L'));
1745    }
1746
1747    #[test]
1748    fn hyperlink_unsafe_url_not_emitted() {
1749        let mut presenter = test_presenter_with_hyperlinks();
1750        let mut buffer = Buffer::new(3, 1);
1751        let mut links = LinkRegistry::new();
1752        let link_id = links.register("https://example.com/\x1b[?2026h");
1753        buffer.set_raw(
1754            0,
1755            0,
1756            Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1757        );
1758
1759        let old = Buffer::new(3, 1);
1760        let diff = BufferDiff::compute(&old, &buffer);
1761        presenter
1762            .present_with_pool(&buffer, &diff, None, Some(&links))
1763            .unwrap();
1764
1765        let output = get_output(presenter);
1766        let output_str = String::from_utf8_lossy(&output);
1767        assert!(
1768            !output_str.contains("\x1b]8;;https://example.com/"),
1769            "unsafe hyperlink URL should be suppressed"
1770        );
1771        assert!(
1772            !output_str.contains("\x1b[?2026h"),
1773            "control payload must never be emitted via OSC 8"
1774        );
1775        assert!(output_str.contains('X'));
1776    }
1777
1778    #[test]
1779    fn hyperlink_overlong_url_not_emitted() {
1780        let mut presenter = test_presenter_with_hyperlinks();
1781        let mut buffer = Buffer::new(3, 1);
1782        let mut links = LinkRegistry::new();
1783        let long_url = format!(
1784            "https://example.com/{}",
1785            "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
1786        );
1787        let link_id = links.register(&long_url);
1788        buffer.set_raw(
1789            0,
1790            0,
1791            Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1792        );
1793
1794        let old = Buffer::new(3, 1);
1795        let diff = BufferDiff::compute(&old, &buffer);
1796        presenter
1797            .present_with_pool(&buffer, &diff, None, Some(&links))
1798            .unwrap();
1799
1800        let output = get_output(presenter);
1801        let output_str = String::from_utf8_lossy(&output);
1802        assert!(
1803            !output_str.contains("\x1b]8;;https://example.com/"),
1804            "overlong hyperlink URL should be suppressed"
1805        );
1806        assert!(output_str.contains('Y'));
1807    }
1808
1809    // =========================================================================
1810    // Single-write-per-frame behavior tests
1811    // =========================================================================
1812
1813    #[test]
1814    fn sync_output_not_wrapped_when_unsupported() {
1815        // When sync_output capability is false, sync sequences should NOT appear
1816        let mut presenter = test_presenter(); // basic caps, sync_output = false
1817        let buffer = Buffer::new(10, 10);
1818        let diff = BufferDiff::new();
1819
1820        presenter.present(&buffer, &diff).unwrap();
1821        let output = get_output(presenter);
1822
1823        // Should NOT contain sync sequences
1824        assert!(
1825            !output
1826                .windows(ansi::SYNC_BEGIN.len())
1827                .any(|w| w == ansi::SYNC_BEGIN),
1828            "Sync begin should not appear when sync_output is disabled"
1829        );
1830        assert!(
1831            !output
1832                .windows(ansi::SYNC_END.len())
1833                .any(|w| w == ansi::SYNC_END),
1834            "Sync end should not appear when sync_output is disabled"
1835        );
1836
1837        // Instead, cursor-hide fallback should be used
1838        assert!(
1839            output.starts_with(ansi::CURSOR_HIDE),
1840            "Fallback should start with cursor hide"
1841        );
1842        assert!(
1843            output.ends_with(ansi::CURSOR_SHOW),
1844            "Fallback should end with cursor show"
1845        );
1846    }
1847
1848    #[test]
1849    fn present_flushes_buffered_output() {
1850        // Verify that present() flushes all buffered output by checking
1851        // that the output contains expected content after present()
1852        let mut presenter = test_presenter();
1853        let mut buffer = Buffer::new(5, 1);
1854        buffer.set_raw(0, 0, Cell::from_char('T'));
1855        buffer.set_raw(1, 0, Cell::from_char('E'));
1856        buffer.set_raw(2, 0, Cell::from_char('S'));
1857        buffer.set_raw(3, 0, Cell::from_char('T'));
1858
1859        let old = Buffer::new(5, 1);
1860        let diff = BufferDiff::compute(&old, &buffer);
1861
1862        presenter.present(&buffer, &diff).unwrap();
1863        let output = get_output(presenter);
1864        let output_str = String::from_utf8_lossy(&output);
1865
1866        // All characters should be present in output (flushed)
1867        assert!(
1868            output_str.contains("TEST"),
1869            "Expected 'TEST' in flushed output"
1870        );
1871    }
1872
1873    #[test]
1874    fn present_stats_reports_cells_and_bytes() {
1875        let mut presenter = test_presenter();
1876        let mut buffer = Buffer::new(10, 1);
1877
1878        // Set 5 cells
1879        for i in 0..5 {
1880            buffer.set_raw(i, 0, Cell::from_char('X'));
1881        }
1882
1883        let old = Buffer::new(10, 1);
1884        let diff = BufferDiff::compute(&old, &buffer);
1885
1886        let stats = presenter.present(&buffer, &diff).unwrap();
1887
1888        // Stats should reflect the changes
1889        assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
1890        assert!(stats.bytes_emitted > 0, "Expected some bytes written");
1891        assert!(stats.run_count >= 1, "Expected at least 1 run");
1892    }
1893
1894    // =========================================================================
1895    // Cursor tracking tests
1896    // =========================================================================
1897
1898    #[test]
1899    fn cursor_tracking_after_wide_char() {
1900        let mut presenter = test_presenter();
1901        presenter.cursor_x = Some(0);
1902        presenter.cursor_y = Some(0);
1903
1904        let mut buffer = Buffer::new(10, 1);
1905        // Wide char at x=0 should advance cursor by 2
1906        buffer.set_raw(0, 0, Cell::from_char('中'));
1907        buffer.set_raw(1, 0, Cell::CONTINUATION);
1908        // Narrow char at x=2
1909        buffer.set_raw(2, 0, Cell::from_char('A'));
1910
1911        let old = Buffer::new(10, 1);
1912        let diff = BufferDiff::compute(&old, &buffer);
1913
1914        presenter.present(&buffer, &diff).unwrap();
1915
1916        // After presenting, cursor should be at x=3 (0 + 2 for wide + 1 for 'A')
1917        // Note: cursor_x gets reset during present(), but we can verify output order
1918        let output = get_output(presenter);
1919        let output_str = String::from_utf8_lossy(&output);
1920
1921        // Both characters should appear
1922        assert!(output_str.contains('中'));
1923        assert!(output_str.contains('A'));
1924    }
1925
1926    #[test]
1927    fn cursor_position_after_multiple_runs() {
1928        let mut presenter = test_presenter();
1929        let mut buffer = Buffer::new(20, 3);
1930
1931        // Create two separate runs on different rows
1932        buffer.set_raw(0, 0, Cell::from_char('A'));
1933        buffer.set_raw(1, 0, Cell::from_char('B'));
1934        buffer.set_raw(5, 2, Cell::from_char('X'));
1935        buffer.set_raw(6, 2, Cell::from_char('Y'));
1936
1937        let old = Buffer::new(20, 3);
1938        let diff = BufferDiff::compute(&old, &buffer);
1939
1940        presenter.present(&buffer, &diff).unwrap();
1941        let output = get_output(presenter);
1942        let output_str = String::from_utf8_lossy(&output);
1943
1944        // All characters should be present
1945        assert!(output_str.contains('A'));
1946        assert!(output_str.contains('B'));
1947        assert!(output_str.contains('X'));
1948        assert!(output_str.contains('Y'));
1949
1950        // Should have multiple CUP sequences (one per run)
1951        let cup_count = output_str.matches("\x1b[").count();
1952        assert!(
1953            cup_count >= 2,
1954            "Expected at least 2 escape sequences for multiple runs"
1955        );
1956    }
1957
1958    // =========================================================================
1959    // Style tracking tests
1960    // =========================================================================
1961
1962    #[test]
1963    fn style_with_all_flags() {
1964        let mut presenter = test_presenter();
1965        let mut buffer = Buffer::new(5, 1);
1966
1967        // Create a cell with all style flags
1968        let all_flags = StyleFlags::BOLD
1969            | StyleFlags::DIM
1970            | StyleFlags::ITALIC
1971            | StyleFlags::UNDERLINE
1972            | StyleFlags::BLINK
1973            | StyleFlags::REVERSE
1974            | StyleFlags::STRIKETHROUGH;
1975
1976        let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
1977        buffer.set_raw(0, 0, cell);
1978
1979        let old = Buffer::new(5, 1);
1980        let diff = BufferDiff::compute(&old, &buffer);
1981
1982        presenter.present(&buffer, &diff).unwrap();
1983        let output = get_output(presenter);
1984        let output_str = String::from_utf8_lossy(&output);
1985
1986        // Should contain the character and SGR sequences
1987        assert!(output_str.contains('X'));
1988        // Should have SGR with multiple attributes (1;2;3;4;5;7;9m pattern)
1989        assert!(output_str.contains("\x1b["), "Expected SGR sequences");
1990    }
1991
1992    #[test]
1993    fn style_transitions_between_different_colors() {
1994        let mut presenter = test_presenter();
1995        let mut buffer = Buffer::new(3, 1);
1996
1997        // Three cells with different foreground colors
1998        buffer.set_raw(
1999            0,
2000            0,
2001            Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2002        );
2003        buffer.set_raw(
2004            1,
2005            0,
2006            Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2007        );
2008        buffer.set_raw(
2009            2,
2010            0,
2011            Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2012        );
2013
2014        let old = Buffer::new(3, 1);
2015        let diff = BufferDiff::compute(&old, &buffer);
2016
2017        presenter.present(&buffer, &diff).unwrap();
2018        let output = get_output(presenter);
2019        let output_str = String::from_utf8_lossy(&output);
2020
2021        // All colors should appear in the output
2022        assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2023        assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2024        assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2025    }
2026
2027    // =========================================================================
2028    // Link tracking tests
2029    // =========================================================================
2030
2031    #[test]
2032    fn link_at_buffer_boundaries() {
2033        let mut presenter = test_presenter();
2034        let mut buffer = Buffer::new(5, 1);
2035        let mut links = LinkRegistry::new();
2036
2037        let link_id = links.register("https://boundary.test");
2038
2039        // Link at first cell
2040        buffer.set_raw(
2041            0,
2042            0,
2043            Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2044        );
2045        // Link at last cell
2046        buffer.set_raw(
2047            4,
2048            0,
2049            Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2050        );
2051
2052        let old = Buffer::new(5, 1);
2053        let diff = BufferDiff::compute(&old, &buffer);
2054
2055        presenter
2056            .present_with_pool(&buffer, &diff, None, Some(&links))
2057            .unwrap();
2058        let output = get_output(presenter);
2059        let output_str = String::from_utf8_lossy(&output);
2060
2061        // Link URL should appear
2062        assert!(output_str.contains("https://boundary.test"));
2063        // Characters should appear
2064        assert!(output_str.contains('F'));
2065        assert!(output_str.contains('L'));
2066    }
2067
2068    #[test]
2069    fn link_state_cleared_after_reset() {
2070        let mut presenter = test_presenter();
2071        let mut links = LinkRegistry::new();
2072        let link_id = links.register("https://example.com");
2073
2074        // Simulate having an open link
2075        presenter.current_link = Some(link_id);
2076        presenter.current_style = Some(CellStyle::default());
2077        presenter.cursor_x = Some(5);
2078        presenter.cursor_y = Some(3);
2079
2080        presenter.reset();
2081
2082        // All state should be cleared
2083        assert!(
2084            presenter.current_link.is_none(),
2085            "current_link should be None after reset"
2086        );
2087        assert!(
2088            presenter.current_style.is_none(),
2089            "current_style should be None after reset"
2090        );
2091        assert!(
2092            presenter.cursor_x.is_none(),
2093            "cursor_x should be None after reset"
2094        );
2095        assert!(
2096            presenter.cursor_y.is_none(),
2097            "cursor_y should be None after reset"
2098        );
2099    }
2100
2101    #[test]
2102    fn link_transitions_linked_unlinked_linked() {
2103        let mut presenter = test_presenter();
2104        let mut buffer = Buffer::new(5, 1);
2105        let mut links = LinkRegistry::new();
2106
2107        let link_id = links.register("https://toggle.test");
2108
2109        // Linked -> Unlinked -> Linked pattern
2110        buffer.set_raw(
2111            0,
2112            0,
2113            Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2114        );
2115        buffer.set_raw(1, 0, Cell::from_char('B')); // no link
2116        buffer.set_raw(
2117            2,
2118            0,
2119            Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2120        );
2121
2122        let old = Buffer::new(5, 1);
2123        let diff = BufferDiff::compute(&old, &buffer);
2124
2125        presenter
2126            .present_with_pool(&buffer, &diff, None, Some(&links))
2127            .unwrap();
2128        let output = get_output(presenter);
2129        let output_str = String::from_utf8_lossy(&output);
2130
2131        // Link URL should appear at least twice (once for A, once for C)
2132        let url_count = output_str.matches("https://toggle.test").count();
2133        assert!(
2134            url_count >= 2,
2135            "Expected link to open at least twice, got {} occurrences",
2136            url_count
2137        );
2138
2139        // Close sequence should appear (after A, and at frame end)
2140        let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
2141        assert!(
2142            close_count >= 2,
2143            "Expected at least 2 link closes, got {}",
2144            close_count
2145        );
2146    }
2147
2148    // =========================================================================
2149    // Multiple frame tests
2150    // =========================================================================
2151
2152    #[test]
2153    fn multiple_presents_maintain_correct_state() {
2154        let mut presenter = test_presenter();
2155        let mut buffer = Buffer::new(10, 1);
2156
2157        // First frame
2158        buffer.set_raw(0, 0, Cell::from_char('1'));
2159        let old = Buffer::new(10, 1);
2160        let diff = BufferDiff::compute(&old, &buffer);
2161        presenter.present(&buffer, &diff).unwrap();
2162
2163        // Second frame - change a different cell
2164        let prev = buffer.clone();
2165        buffer.set_raw(1, 0, Cell::from_char('2'));
2166        let diff = BufferDiff::compute(&prev, &buffer);
2167        presenter.present(&buffer, &diff).unwrap();
2168
2169        // Third frame - change another cell
2170        let prev = buffer.clone();
2171        buffer.set_raw(2, 0, Cell::from_char('3'));
2172        let diff = BufferDiff::compute(&prev, &buffer);
2173        presenter.present(&buffer, &diff).unwrap();
2174
2175        let output = get_output(presenter);
2176        let output_str = String::from_utf8_lossy(&output);
2177
2178        // All numbers should appear in final output
2179        assert!(output_str.contains('1'));
2180        assert!(output_str.contains('2'));
2181        assert!(output_str.contains('3'));
2182    }
2183
2184    // =========================================================================
2185    // SGR Delta Engine tests (bd-4kq0.2.1)
2186    // =========================================================================
2187
2188    #[test]
2189    fn sgr_delta_fg_only_change_no_reset() {
2190        // When only fg changes, delta should NOT emit reset
2191        let mut presenter = test_presenter();
2192        let mut buffer = Buffer::new(3, 1);
2193
2194        let fg1 = PackedRgba::rgb(255, 0, 0);
2195        let fg2 = PackedRgba::rgb(0, 255, 0);
2196        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2197        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2198
2199        let old = Buffer::new(3, 1);
2200        let diff = BufferDiff::compute(&old, &buffer);
2201
2202        presenter.present(&buffer, &diff).unwrap();
2203        let output = get_output(presenter);
2204        let output_str = String::from_utf8_lossy(&output);
2205
2206        // Count SGR resets - the first cell needs a reset (from None state),
2207        // but the second cell should use delta (no reset)
2208        let reset_count = output_str.matches("\x1b[0m").count();
2209        // One reset at start (for first cell from unknown state) + one at frame end
2210        assert_eq!(
2211            reset_count, 2,
2212            "Expected 2 resets (initial + frame end), got {} in: {:?}",
2213            reset_count, output_str
2214        );
2215    }
2216
2217    #[test]
2218    fn sgr_delta_bg_only_change_no_reset() {
2219        let mut presenter = test_presenter();
2220        let mut buffer = Buffer::new(3, 1);
2221
2222        let bg1 = PackedRgba::rgb(0, 0, 255);
2223        let bg2 = PackedRgba::rgb(255, 255, 0);
2224        buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2225        buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2226
2227        let old = Buffer::new(3, 1);
2228        let diff = BufferDiff::compute(&old, &buffer);
2229
2230        presenter.present(&buffer, &diff).unwrap();
2231        let output = get_output(presenter);
2232        let output_str = String::from_utf8_lossy(&output);
2233
2234        // Only 2 resets: initial cell + frame end
2235        let reset_count = output_str.matches("\x1b[0m").count();
2236        assert_eq!(
2237            reset_count, 2,
2238            "Expected 2 resets, got {} in: {:?}",
2239            reset_count, output_str
2240        );
2241    }
2242
2243    #[test]
2244    fn sgr_delta_attr_addition_no_reset() {
2245        let mut presenter = test_presenter();
2246        let mut buffer = Buffer::new(3, 1);
2247
2248        // First cell: bold. Second cell: bold + italic
2249        let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2250        let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2251        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2252        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2253
2254        let old = Buffer::new(3, 1);
2255        let diff = BufferDiff::compute(&old, &buffer);
2256
2257        presenter.present(&buffer, &diff).unwrap();
2258        let output = get_output(presenter);
2259        let output_str = String::from_utf8_lossy(&output);
2260
2261        // Second cell should add italic (code 3) without reset
2262        let reset_count = output_str.matches("\x1b[0m").count();
2263        assert_eq!(
2264            reset_count, 2,
2265            "Expected 2 resets, got {} in: {:?}",
2266            reset_count, output_str
2267        );
2268        // Should contain italic-on code for the delta
2269        assert!(
2270            output_str.contains("\x1b[3m"),
2271            "Expected italic-on sequence in: {:?}",
2272            output_str
2273        );
2274    }
2275
2276    #[test]
2277    fn sgr_delta_attr_removal_uses_off_code() {
2278        let mut presenter = test_presenter();
2279        let mut buffer = Buffer::new(3, 1);
2280
2281        // First cell: bold+italic. Second cell: bold only
2282        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2283        let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2284        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2285        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2286
2287        let old = Buffer::new(3, 1);
2288        let diff = BufferDiff::compute(&old, &buffer);
2289
2290        presenter.present(&buffer, &diff).unwrap();
2291        let output = get_output(presenter);
2292        let output_str = String::from_utf8_lossy(&output);
2293
2294        // Should contain italic-off code (23) for delta
2295        assert!(
2296            output_str.contains("\x1b[23m"),
2297            "Expected italic-off sequence in: {:?}",
2298            output_str
2299        );
2300        // Only 2 resets (initial + frame end), not 3
2301        let reset_count = output_str.matches("\x1b[0m").count();
2302        assert_eq!(
2303            reset_count, 2,
2304            "Expected 2 resets, got {} in: {:?}",
2305            reset_count, output_str
2306        );
2307    }
2308
2309    #[test]
2310    fn sgr_delta_bold_dim_collateral_re_enables() {
2311        // Bold off (code 22) also disables Dim. If Dim should remain,
2312        // the delta engine must re-enable it.
2313        let mut presenter = test_presenter();
2314        let mut buffer = Buffer::new(3, 1);
2315
2316        // First cell: Bold + Dim. Second cell: Dim only
2317        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2318        let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2319        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2320        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2321
2322        let old = Buffer::new(3, 1);
2323        let diff = BufferDiff::compute(&old, &buffer);
2324
2325        presenter.present(&buffer, &diff).unwrap();
2326        let output = get_output(presenter);
2327        let output_str = String::from_utf8_lossy(&output);
2328
2329        // Should contain bold-off (22) and then dim re-enable (2)
2330        assert!(
2331            output_str.contains("\x1b[22m"),
2332            "Expected bold-off (22) in: {:?}",
2333            output_str
2334        );
2335        assert!(
2336            output_str.contains("\x1b[2m"),
2337            "Expected dim re-enable (2) in: {:?}",
2338            output_str
2339        );
2340    }
2341
2342    #[test]
2343    fn sgr_delta_same_style_no_output() {
2344        let mut presenter = test_presenter();
2345        let mut buffer = Buffer::new(3, 1);
2346
2347        let fg = PackedRgba::rgb(255, 0, 0);
2348        let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2349        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2350        buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2351        buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2352
2353        let old = Buffer::new(3, 1);
2354        let diff = BufferDiff::compute(&old, &buffer);
2355
2356        presenter.present(&buffer, &diff).unwrap();
2357        let output = get_output(presenter);
2358        let output_str = String::from_utf8_lossy(&output);
2359
2360        // Only 1 fg color sequence (style set once for all three cells)
2361        let fg_count = output_str.matches("38;2;255;0;0").count();
2362        assert_eq!(
2363            fg_count, 1,
2364            "Expected 1 fg sequence, got {} in: {:?}",
2365            fg_count, output_str
2366        );
2367    }
2368
2369    #[test]
2370    fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2371        // Test that delta output is never larger than reset+apply would be
2372        // for a variety of style transitions
2373        let transitions: Vec<(CellStyle, CellStyle)> = vec![
2374            // Only fg change
2375            (
2376                CellStyle {
2377                    fg: PackedRgba::rgb(255, 0, 0),
2378                    bg: PackedRgba::TRANSPARENT,
2379                    attrs: StyleFlags::empty(),
2380                },
2381                CellStyle {
2382                    fg: PackedRgba::rgb(0, 255, 0),
2383                    bg: PackedRgba::TRANSPARENT,
2384                    attrs: StyleFlags::empty(),
2385                },
2386            ),
2387            // Only bg change
2388            (
2389                CellStyle {
2390                    fg: PackedRgba::TRANSPARENT,
2391                    bg: PackedRgba::rgb(255, 0, 0),
2392                    attrs: StyleFlags::empty(),
2393                },
2394                CellStyle {
2395                    fg: PackedRgba::TRANSPARENT,
2396                    bg: PackedRgba::rgb(0, 0, 255),
2397                    attrs: StyleFlags::empty(),
2398                },
2399            ),
2400            // Only attr addition
2401            (
2402                CellStyle {
2403                    fg: PackedRgba::rgb(100, 100, 100),
2404                    bg: PackedRgba::TRANSPARENT,
2405                    attrs: StyleFlags::BOLD,
2406                },
2407                CellStyle {
2408                    fg: PackedRgba::rgb(100, 100, 100),
2409                    bg: PackedRgba::TRANSPARENT,
2410                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2411                },
2412            ),
2413            // Attr removal
2414            (
2415                CellStyle {
2416                    fg: PackedRgba::rgb(100, 100, 100),
2417                    bg: PackedRgba::TRANSPARENT,
2418                    attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2419                },
2420                CellStyle {
2421                    fg: PackedRgba::rgb(100, 100, 100),
2422                    bg: PackedRgba::TRANSPARENT,
2423                    attrs: StyleFlags::BOLD,
2424                },
2425            ),
2426        ];
2427
2428        for (old_style, new_style) in &transitions {
2429            // Measure delta cost
2430            let delta_buf = {
2431                let mut delta_presenter = {
2432                    let caps = TerminalCapabilities::basic();
2433                    Presenter::new(Vec::new(), caps)
2434                };
2435                delta_presenter.current_style = Some(*old_style);
2436                delta_presenter
2437                    .emit_style_delta(*old_style, *new_style)
2438                    .unwrap();
2439                delta_presenter.into_inner().unwrap()
2440            };
2441
2442            // Measure reset+apply cost
2443            let reset_buf = {
2444                let mut reset_presenter = {
2445                    let caps = TerminalCapabilities::basic();
2446                    Presenter::new(Vec::new(), caps)
2447                };
2448                reset_presenter.emit_style_full(*new_style).unwrap();
2449                reset_presenter.into_inner().unwrap()
2450            };
2451
2452            assert!(
2453                delta_buf.len() <= reset_buf.len(),
2454                "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2455                 Delta: {:?}\nReset: {:?}",
2456                delta_buf.len(),
2457                reset_buf.len(),
2458                old_style,
2459                new_style,
2460                String::from_utf8_lossy(&delta_buf),
2461                String::from_utf8_lossy(&reset_buf),
2462            );
2463        }
2464    }
2465
2466    /// Generate a deterministic JSONL evidence ledger proving the SGR delta engine
2467    /// emits fewer (or equal) bytes than reset+apply for every transition.
2468    ///
2469    /// Each line is a JSON object with:
2470    ///   seed, from_fg, from_bg, from_attrs, to_fg, to_bg, to_attrs,
2471    ///   delta_bytes, baseline_bytes, cost_delta, used_fallback
2472    #[test]
2473    fn sgr_delta_evidence_ledger() {
2474        use std::io::Write as _;
2475
2476        // Deterministic seed for reproducibility
2477        const SEED: u64 = 0xDEAD_BEEF_CAFE;
2478
2479        // Simple LCG for deterministic pseudorandom values
2480        let mut rng_state = SEED;
2481        let mut next_u64 = || -> u64 {
2482            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2483            rng_state
2484        };
2485
2486        let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2487            let v = rng();
2488            let fg = if v & 1 == 0 {
2489                PackedRgba::TRANSPARENT
2490            } else {
2491                let r = ((v >> 8) & 0xFF) as u8;
2492                let g = ((v >> 16) & 0xFF) as u8;
2493                let b = ((v >> 24) & 0xFF) as u8;
2494                PackedRgba::rgb(r, g, b)
2495            };
2496            let v2 = rng();
2497            let bg = if v2 & 1 == 0 {
2498                PackedRgba::TRANSPARENT
2499            } else {
2500                let r = ((v2 >> 8) & 0xFF) as u8;
2501                let g = ((v2 >> 16) & 0xFF) as u8;
2502                let b = ((v2 >> 24) & 0xFF) as u8;
2503                PackedRgba::rgb(r, g, b)
2504            };
2505            let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2506            CellStyle { fg, bg, attrs }
2507        };
2508
2509        let mut ledger = Vec::new();
2510        let num_transitions = 200;
2511
2512        for i in 0..num_transitions {
2513            let old_style = random_style(&mut next_u64);
2514            let new_style = random_style(&mut next_u64);
2515
2516            // Measure delta cost
2517            let mut delta_p = {
2518                let caps = TerminalCapabilities::basic();
2519                Presenter::new(Vec::new(), caps)
2520            };
2521            delta_p.current_style = Some(old_style);
2522            delta_p.emit_style_delta(old_style, new_style).unwrap();
2523            let delta_out = delta_p.into_inner().unwrap();
2524
2525            // Measure reset+apply cost
2526            let mut reset_p = {
2527                let caps = TerminalCapabilities::basic();
2528                Presenter::new(Vec::new(), caps)
2529            };
2530            reset_p.emit_style_full(new_style).unwrap();
2531            let reset_out = reset_p.into_inner().unwrap();
2532
2533            let delta_bytes = delta_out.len();
2534            let baseline_bytes = reset_out.len();
2535
2536            // Compute whether fallback was used (delta >= baseline means fallback likely)
2537            let attrs_removed = old_style.attrs & !new_style.attrs;
2538            let removed_count = attrs_removed.bits().count_ones();
2539            let fg_changed = old_style.fg != new_style.fg;
2540            let bg_changed = old_style.bg != new_style.bg;
2541            let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2542
2543            // Assert cost dominance
2544            assert!(
2545                delta_bytes <= baseline_bytes,
2546                "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2547            );
2548
2549            // Emit JSONL record
2550            writeln!(
2551                &mut ledger,
2552                "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2553                 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2554                 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2555                 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2556                old_style.fg,
2557                old_style.bg,
2558                old_style.attrs.bits(),
2559                new_style.fg,
2560                new_style.bg,
2561                new_style.attrs.bits(),
2562                baseline_bytes as isize - delta_bytes as isize,
2563            )
2564            .unwrap();
2565        }
2566
2567        // Verify we produced valid JSONL (every line parses)
2568        let text = String::from_utf8(ledger).unwrap();
2569        let lines: Vec<&str> = text.lines().collect();
2570        assert_eq!(lines.len(), num_transitions);
2571
2572        // Verify aggregate: total savings should be non-negative
2573        let mut total_saved: isize = 0;
2574        for line in &lines {
2575            // Quick parse of cost_delta field
2576            let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2577            let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2578            let cd: isize = line[cd_start..cd_end].parse().unwrap();
2579            total_saved += cd;
2580        }
2581        assert!(
2582            total_saved >= 0,
2583            "Total byte savings should be non-negative, got {total_saved}"
2584        );
2585    }
2586
2587    /// E2E style stress test: scripted style churn across a full buffer
2588    /// with byte metrics proving delta engine correctness under load.
2589    #[test]
2590    fn e2e_style_stress_with_byte_metrics() {
2591        let width = 40u16;
2592        let height = 10u16;
2593
2594        // Build a buffer with maximum style diversity
2595        let mut buffer = Buffer::new(width, height);
2596        for y in 0..height {
2597            for x in 0..width {
2598                let i = (y as usize * width as usize + x as usize) as u8;
2599                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2600                let bg = if i.is_multiple_of(4) {
2601                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2602                } else {
2603                    PackedRgba::TRANSPARENT
2604                };
2605                let flags = StyleFlags::from_bits_truncate(i % 128);
2606                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2607                let cell = Cell::from_char(ch)
2608                    .with_fg(fg)
2609                    .with_bg(bg)
2610                    .with_attrs(CellAttrs::new(flags, 0));
2611                buffer.set_raw(x, y, cell);
2612            }
2613        }
2614
2615        // Present from blank (first frame)
2616        let blank = Buffer::new(width, height);
2617        let diff = BufferDiff::compute(&blank, &buffer);
2618        let mut presenter = test_presenter();
2619        presenter.present(&buffer, &diff).unwrap();
2620        let frame1_bytes = presenter.into_inner().unwrap().len();
2621
2622        // Build second buffer: shift all styles by one position (churn)
2623        let mut buffer2 = Buffer::new(width, height);
2624        for y in 0..height {
2625            for x in 0..width {
2626                let i = (y as usize * width as usize + x as usize + 1) as u8;
2627                let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2628                let bg = if i.is_multiple_of(4) {
2629                    PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2630                } else {
2631                    PackedRgba::TRANSPARENT
2632                };
2633                let flags = StyleFlags::from_bits_truncate(i % 128);
2634                let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2635                let cell = Cell::from_char(ch)
2636                    .with_fg(fg)
2637                    .with_bg(bg)
2638                    .with_attrs(CellAttrs::new(flags, 0));
2639                buffer2.set_raw(x, y, cell);
2640            }
2641        }
2642
2643        // Second frame: incremental update should use delta engine
2644        let diff2 = BufferDiff::compute(&buffer, &buffer2);
2645        let mut presenter2 = test_presenter();
2646        presenter2.present(&buffer2, &diff2).unwrap();
2647        let frame2_bytes = presenter2.into_inner().unwrap().len();
2648
2649        // Incremental should be smaller than full redraw since delta
2650        // engine can reuse partial style state
2651        assert!(
2652            frame2_bytes > 0,
2653            "Second frame should produce output for style churn"
2654        );
2655        assert!(!diff2.is_empty(), "Style shift should produce changes");
2656
2657        // Verify frame2 is at most frame1 size (delta should never be worse
2658        // than a full redraw for the same number of changed cells)
2659        // Note: frame2 may differ in size due to different diff (changed cells
2660        // vs all cells), so just verify it's reasonable.
2661        assert!(
2662            frame2_bytes <= frame1_bytes * 2,
2663            "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2664        );
2665    }
2666
2667    // =========================================================================
2668    // DP Cost Model Tests (bd-4kq0.2.2)
2669    // =========================================================================
2670
2671    #[test]
2672    fn cost_model_empty_row_single_run() {
2673        // Single run on a row should always use Sparse (no merge benefit)
2674        let runs = [ChangeRun::new(5, 10, 20)];
2675        let plan = cost_model::plan_row(&runs, None, None);
2676        assert_eq!(plan.spans().len(), 1);
2677        assert_eq!(plan.spans()[0].x0, 10);
2678        assert_eq!(plan.spans()[0].x1, 20);
2679        assert!(plan.total_cost() > 0);
2680    }
2681
2682    #[test]
2683    fn cost_model_full_row_merges() {
2684        // Two small runs far apart on same row - gap is smaller than 2x CUP overhead
2685        // Runs at columns 0-2 and 77-79 on an 80-col row
2686        // Sparse: CUP + 3 cells + CUP + 3 cells
2687        // Merged: CUP + 80 cells but with gap overhead
2688        // This should stay sparse since the gap is very large
2689        let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2690        let plan = cost_model::plan_row(&runs, None, None);
2691        // Large gap (74 cells * 2 overhead = 148) vs CUP savings (~8) => no merge.
2692        assert_eq!(plan.spans().len(), 2);
2693        assert_eq!(plan.spans()[0].x0, 0);
2694        assert_eq!(plan.spans()[0].x1, 2);
2695        assert_eq!(plan.spans()[1].x0, 77);
2696        assert_eq!(plan.spans()[1].x1, 79);
2697    }
2698
2699    #[test]
2700    fn cost_model_adjacent_runs_merge() {
2701        // Many single-cell runs with 1-cell gaps should merge
2702        // 8 single-cell runs at columns 10, 12, 14, 16, 18, 20, 22, 24
2703        let runs = [
2704            ChangeRun::new(3, 10, 10),
2705            ChangeRun::new(3, 12, 12),
2706            ChangeRun::new(3, 14, 14),
2707            ChangeRun::new(3, 16, 16),
2708            ChangeRun::new(3, 18, 18),
2709            ChangeRun::new(3, 20, 20),
2710            ChangeRun::new(3, 22, 22),
2711            ChangeRun::new(3, 24, 24),
2712        ];
2713        let plan = cost_model::plan_row(&runs, None, None);
2714        // Sparse: 1 CUP + 7 CUF(2) * 4 bytes + 8 cells = ~7+28+8 = 43
2715        // Merged: 1 CUP + 8 changed + 7 gap * 2 = 7+8+14 = 29
2716        assert_eq!(plan.spans().len(), 1);
2717        assert_eq!(plan.spans()[0].x0, 10);
2718        assert_eq!(plan.spans()[0].x1, 24);
2719    }
2720
2721    #[test]
2722    fn cost_model_single_cell_stays_sparse() {
2723        let runs = [ChangeRun::new(0, 40, 40)];
2724        let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2725        assert_eq!(plan.spans().len(), 1);
2726        assert_eq!(plan.spans()[0].x0, 40);
2727        assert_eq!(plan.spans()[0].x1, 40);
2728    }
2729
2730    #[test]
2731    fn cost_model_cup_vs_cha_vs_cuf() {
2732        // CUF should be cheapest for small forward moves on same row
2733        assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
2734        assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
2735
2736        // CHA should be cheapest for backward moves on same row (vs CUP)
2737        let cha = cost_model::cha_cost(5);
2738        let cup = cost_model::cup_cost(0, 5);
2739        assert!(cha <= cup);
2740
2741        // Cheapest move from known position (same row, forward 1)
2742        let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
2743        assert_eq!(cost, 3); // CUF(1) = "\x1b[C" = 3 bytes
2744    }
2745
2746    #[test]
2747    fn cost_model_digit_estimation_accuracy() {
2748        // Verify CUP cost estimates are accurate by comparing to actual output
2749        let mut buf = Vec::new();
2750        ansi::cup(&mut buf, 0, 0).unwrap();
2751        assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
2752
2753        buf.clear();
2754        ansi::cup(&mut buf, 9, 9).unwrap();
2755        assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
2756
2757        buf.clear();
2758        ansi::cup(&mut buf, 99, 99).unwrap();
2759        assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
2760
2761        buf.clear();
2762        ansi::cha(&mut buf, 0).unwrap();
2763        assert_eq!(buf.len(), cost_model::cha_cost(0));
2764
2765        buf.clear();
2766        ansi::cuf(&mut buf, 1).unwrap();
2767        assert_eq!(buf.len(), cost_model::cuf_cost(1));
2768
2769        buf.clear();
2770        ansi::cuf(&mut buf, 10).unwrap();
2771        assert_eq!(buf.len(), cost_model::cuf_cost(10));
2772    }
2773
2774    #[test]
2775    fn cost_model_merged_row_produces_correct_output() {
2776        // Verify that merged emission produces the same visual result as sparse
2777        let width = 30u16;
2778        let mut buffer = Buffer::new(width, 1);
2779
2780        // Set up scattered changes: columns 5, 10, 15, 20
2781        for col in [5u16, 10, 15, 20] {
2782            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2783            buffer.set_raw(col, 0, Cell::from_char(ch));
2784        }
2785
2786        let old = Buffer::new(width, 1);
2787        let diff = BufferDiff::compute(&old, &buffer);
2788
2789        // Present and verify output contains expected characters
2790        let mut presenter = test_presenter();
2791        presenter.present(&buffer, &diff).unwrap();
2792        let output = presenter.into_inner().unwrap();
2793        let output_str = String::from_utf8_lossy(&output);
2794
2795        for col in [5u16, 10, 15, 20] {
2796            let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2797            assert!(
2798                output_str.contains(ch),
2799                "Missing character '{ch}' at col {col} in output"
2800            );
2801        }
2802    }
2803
2804    #[test]
2805    fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
2806        // Verify move_cursor_optimal uses CUF for small forward moves
2807        let mut presenter = test_presenter();
2808        presenter.cursor_x = Some(5);
2809        presenter.cursor_y = Some(0);
2810        presenter.move_cursor_optimal(6, 0).unwrap();
2811        let output = presenter.into_inner().unwrap();
2812        // CUF(1) = "\x1b[C"
2813        assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
2814    }
2815
2816    #[test]
2817    fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
2818        let mut presenter = test_presenter();
2819        presenter.cursor_x = Some(10);
2820        presenter.cursor_y = Some(3);
2821
2822        let target_x = 2;
2823        let target_y = 3;
2824        let cha_cost = cost_model::cha_cost(target_x);
2825        let cup_cost = cost_model::cup_cost(target_y, target_x);
2826        assert!(
2827            cha_cost <= cup_cost,
2828            "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
2829        );
2830
2831        presenter.move_cursor_optimal(target_x, target_y).unwrap();
2832        let output = presenter.into_inner().unwrap();
2833        let mut expected = Vec::new();
2834        ansi::cha(&mut expected, target_x).unwrap();
2835        assert_eq!(output, expected, "Should use CHA for backward move");
2836    }
2837
2838    #[test]
2839    fn cost_model_optimal_cursor_uses_cup_on_row_change() {
2840        let mut presenter = test_presenter();
2841        presenter.cursor_x = Some(4);
2842        presenter.cursor_y = Some(1);
2843
2844        presenter.move_cursor_optimal(7, 4).unwrap();
2845        let output = presenter.into_inner().unwrap();
2846        let mut expected = Vec::new();
2847        ansi::cup(&mut expected, 4, 7).unwrap();
2848        assert_eq!(output, expected, "Should use CUP when row changes");
2849    }
2850
2851    #[test]
2852    fn cost_model_chooses_full_row_when_cheaper() {
2853        // Create a scenario where merged is definitely cheaper:
2854        // 10 single-cell runs with 1-cell gaps on the same row
2855        let width = 40u16;
2856        let mut buffer = Buffer::new(width, 1);
2857
2858        // Every other column: 0, 2, 4, 6, 8, 10, 12, 14, 16, 18
2859        for col in (0..20).step_by(2) {
2860            buffer.set_raw(col, 0, Cell::from_char('X'));
2861        }
2862
2863        let old = Buffer::new(width, 1);
2864        let diff = BufferDiff::compute(&old, &buffer);
2865        let runs = diff.runs();
2866
2867        // The cost model should merge (many small gaps < many CUP costs)
2868        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2869        if row_runs.len() > 1 {
2870            let plan = cost_model::plan_row(&row_runs, None, None);
2871            assert!(
2872                plan.spans().len() == 1,
2873                "Expected single merged span for many small runs, got {} spans",
2874                plan.spans().len()
2875            );
2876            assert_eq!(plan.spans()[0].x0, 0);
2877            assert_eq!(plan.spans()[0].x1, 18);
2878        }
2879    }
2880
2881    #[test]
2882    fn perf_cost_model_overhead() {
2883        // Verify the cost model planning is fast (microsecond scale)
2884        use std::time::Instant;
2885
2886        let runs: Vec<ChangeRun> = (0..100)
2887            .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
2888            .collect();
2889
2890        let (iterations, max_ms) = if cfg!(debug_assertions) {
2891            (1_000, 1_000u128)
2892        } else {
2893            (10_000, 500u128)
2894        };
2895
2896        let start = Instant::now();
2897        for _ in 0..iterations {
2898            let _ = cost_model::plan_row(&runs, None, None);
2899        }
2900        let elapsed = start.elapsed();
2901
2902        // Keep this generous in debug builds to avoid flaky perf assertions.
2903        assert!(
2904            elapsed.as_millis() < max_ms,
2905            "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
2906        );
2907    }
2908
2909    #[test]
2910    fn perf_legacy_vs_dp_worst_case_sparse() {
2911        use std::time::Instant;
2912
2913        let width = 200u16;
2914        let height = 1u16;
2915        let mut buffer = Buffer::new(width, height);
2916
2917        // Two dense clusters with a large gap between them.
2918        for col in (0..40).step_by(2) {
2919            buffer.set_raw(col, 0, Cell::from_char('X'));
2920        }
2921        for col in (160..200).step_by(2) {
2922            buffer.set_raw(col, 0, Cell::from_char('Y'));
2923        }
2924
2925        let blank = Buffer::new(width, height);
2926        let diff = BufferDiff::compute(&blank, &buffer);
2927        let runs = diff.runs();
2928        let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2929
2930        let dp_plan = cost_model::plan_row(&row_runs, None, None);
2931        let legacy_spans = legacy_plan_row(&row_runs, None, None);
2932
2933        let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
2934        let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
2935
2936        assert!(
2937            dp_output.len() <= legacy_output.len(),
2938            "DP output should be <= legacy output (dp={}, legacy={})",
2939            dp_output.len(),
2940            legacy_output.len()
2941        );
2942
2943        let (iterations, max_ms) = if cfg!(debug_assertions) {
2944            (1_000, 1_000u128)
2945        } else {
2946            (10_000, 500u128)
2947        };
2948        let start = Instant::now();
2949        for _ in 0..iterations {
2950            let _ = cost_model::plan_row(&row_runs, None, None);
2951        }
2952        let dp_elapsed = start.elapsed();
2953
2954        let start = Instant::now();
2955        for _ in 0..iterations {
2956            let _ = legacy_plan_row(&row_runs, None, None);
2957        }
2958        let legacy_elapsed = start.elapsed();
2959
2960        assert!(
2961            dp_elapsed.as_millis() < max_ms,
2962            "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
2963        );
2964
2965        let _ = legacy_elapsed;
2966    }
2967
2968    // =========================================================================
2969    // Presenter Perf + Golden Outputs (bd-4kq0.2.3)
2970    // =========================================================================
2971
2972    /// Build a deterministic "style-heavy" scene: every cell has a unique style.
2973    fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
2974        let mut buffer = Buffer::new(width, height);
2975        let mut rng = seed;
2976        let mut next = || -> u64 {
2977            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2978            rng
2979        };
2980        for y in 0..height {
2981            for x in 0..width {
2982                let v = next();
2983                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2984                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
2985                let bg = if v & 3 == 0 {
2986                    PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
2987                } else {
2988                    PackedRgba::TRANSPARENT
2989                };
2990                let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
2991                let cell = Cell::from_char(ch)
2992                    .with_fg(fg)
2993                    .with_bg(bg)
2994                    .with_attrs(CellAttrs::new(flags, 0));
2995                buffer.set_raw(x, y, cell);
2996            }
2997        }
2998        buffer
2999    }
3000
3001    /// Build a "sparse-update" scene: only ~10% of cells differ between frames.
3002    fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3003        let mut buffer = base.clone();
3004        let width = base.width();
3005        let height = base.height();
3006        let mut rng = seed;
3007        let mut next = || -> u64 {
3008            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3009            rng
3010        };
3011        let change_count = (width as usize * height as usize) / 10;
3012        for _ in 0..change_count {
3013            let v = next();
3014            let x = (v % width as u64) as u16;
3015            let y = ((v >> 16) % height as u64) as u16;
3016            let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3017            buffer.set_raw(x, y, Cell::from_char(ch));
3018        }
3019        buffer
3020    }
3021
3022    #[test]
3023    fn snapshot_presenter_equivalence() {
3024        // Golden snapshot: style-heavy 40x10 scene with deterministic seed.
3025        // The output hash must be stable across runs.
3026        let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3027        let blank = Buffer::new(40, 10);
3028        let diff = BufferDiff::compute(&blank, &buffer);
3029
3030        let mut presenter = test_presenter();
3031        presenter.present(&buffer, &diff).unwrap();
3032        let output = presenter.into_inner().unwrap();
3033
3034        // Compute checksum for golden comparison
3035        let checksum = {
3036            let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis
3037            for &byte in &output {
3038                hash ^= byte as u64;
3039                hash = hash.wrapping_mul(0x100000001b3); // FNV prime
3040            }
3041            hash
3042        };
3043
3044        // Verify determinism: same seed + scene = same output
3045        let mut presenter2 = test_presenter();
3046        presenter2.present(&buffer, &diff).unwrap();
3047        let output2 = presenter2.into_inner().unwrap();
3048        assert_eq!(output, output2, "Presenter output must be deterministic");
3049
3050        // Log golden checksum for the record
3051        let _ = checksum; // Used in JSONL test below
3052    }
3053
3054    #[test]
3055    fn perf_presenter_microbench() {
3056        use std::env;
3057        use std::io::Write as _;
3058        use std::time::Instant;
3059
3060        let width = 120u16;
3061        let height = 40u16;
3062        let seed = 0x00BE_EFCA_FE42;
3063        let scene = build_style_heavy_scene(width, height, seed);
3064        let blank = Buffer::new(width, height);
3065        let diff_full = BufferDiff::compute(&blank, &scene);
3066
3067        // Also build a sparse update scene
3068        let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3069        let diff_sparse = BufferDiff::compute(&scene, &scene2);
3070
3071        let mut jsonl = Vec::new();
3072        let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3073            .ok()
3074            .and_then(|value| value.parse::<u32>().ok())
3075            .unwrap_or(50);
3076
3077        let runs_full = diff_full.runs();
3078        let runs_sparse = diff_sparse.runs();
3079
3080        let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3081            let mut idx = 0;
3082            let mut total_cost = 0usize;
3083            let mut span_count = 0usize;
3084            let mut prev_x = None;
3085            let mut prev_y = None;
3086
3087            while idx < runs.len() {
3088                let y = runs[idx].y;
3089                let start = idx;
3090                while idx < runs.len() && runs[idx].y == y {
3091                    idx += 1;
3092                }
3093
3094                let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3095                span_count += plan.spans().len();
3096                total_cost = total_cost.saturating_add(plan.total_cost());
3097                if let Some(last) = plan.spans().last() {
3098                    prev_x = Some(last.x1);
3099                    prev_y = Some(y);
3100                }
3101            }
3102
3103            (total_cost, span_count)
3104        };
3105
3106        for i in 0..iterations {
3107            let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3108                (&diff_full, &scene, &runs_full, "full")
3109            } else {
3110                (&diff_sparse, &scene2, &runs_sparse, "sparse")
3111            };
3112
3113            let plan_start = Instant::now();
3114            let (plan_cost, plan_spans) = plan_rows(runs_ref);
3115            let plan_time_us = plan_start.elapsed().as_micros() as u64;
3116
3117            let mut presenter = test_presenter();
3118            let start = Instant::now();
3119            let stats = presenter.present(buf_ref, diff_ref).unwrap();
3120            let elapsed_us = start.elapsed().as_micros() as u64;
3121            let output = presenter.into_inner().unwrap();
3122
3123            // FNV-1a checksum
3124            let checksum = {
3125                let mut hash: u64 = 0xcbf29ce484222325;
3126                for &b in &output {
3127                    hash ^= b as u64;
3128                    hash = hash.wrapping_mul(0x100000001b3);
3129                }
3130                hash
3131            };
3132
3133            writeln!(
3134                &mut jsonl,
3135                "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3136                 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3137                 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3138                 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3139                 \"emit_time_us\":{elapsed_us},\
3140                 \"checksum\":\"{checksum:016x}\"}}",
3141                stats.cells_changed, stats.run_count, stats.bytes_emitted,
3142            )
3143            .unwrap();
3144        }
3145
3146        let text = String::from_utf8(jsonl).unwrap();
3147        let lines: Vec<&str> = text.lines().collect();
3148        assert_eq!(lines.len(), iterations as usize);
3149
3150        // Parse and verify: full frames should be deterministic (same checksum)
3151        let full_checksums: Vec<&str> = lines
3152            .iter()
3153            .filter(|l| l.contains("\"full\""))
3154            .map(|l| {
3155                let start = l.find("\"checksum\":\"").unwrap() + 12;
3156                let end = l[start..].find('"').unwrap() + start;
3157                &l[start..end]
3158            })
3159            .collect();
3160        assert!(full_checksums.len() > 1);
3161        assert!(
3162            full_checksums.windows(2).all(|w| w[0] == w[1]),
3163            "Full frame checksums should be identical across runs"
3164        );
3165
3166        // Sparse frame bytes should be less than full frame bytes
3167        let full_bytes: Vec<u64> = lines
3168            .iter()
3169            .filter(|l| l.contains("\"full\""))
3170            .map(|l| {
3171                let start = l.find("\"bytes\":").unwrap() + 8;
3172                let end = l[start..].find(',').unwrap() + start;
3173                l[start..end].parse::<u64>().unwrap()
3174            })
3175            .collect();
3176        let sparse_bytes: Vec<u64> = lines
3177            .iter()
3178            .filter(|l| l.contains("\"sparse\""))
3179            .map(|l| {
3180                let start = l.find("\"bytes\":").unwrap() + 8;
3181                let end = l[start..].find(',').unwrap() + start;
3182                l[start..end].parse::<u64>().unwrap()
3183            })
3184            .collect();
3185
3186        let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3187        let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3188        assert!(
3189            avg_sparse < avg_full,
3190            "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3191        );
3192    }
3193
3194    #[test]
3195    fn perf_emit_style_delta_microbench() {
3196        use std::env;
3197        use std::io::Write as _;
3198        use std::time::Instant;
3199
3200        let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3201            .ok()
3202            .and_then(|value| value.parse::<u32>().ok())
3203            .unwrap_or(200);
3204        let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3205        let emit_json = mode != "raw";
3206
3207        let mut styles = Vec::with_capacity(128);
3208        let mut rng = 0x00A5_A51E_AF42_u64;
3209        let mut next = || -> u64 {
3210            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3211            rng
3212        };
3213
3214        for _ in 0..128 {
3215            let v = next();
3216            let fg = PackedRgba::rgb(
3217                (v & 0xFF) as u8,
3218                ((v >> 8) & 0xFF) as u8,
3219                ((v >> 16) & 0xFF) as u8,
3220            );
3221            let bg = PackedRgba::rgb(
3222                ((v >> 24) & 0xFF) as u8,
3223                ((v >> 32) & 0xFF) as u8,
3224                ((v >> 40) & 0xFF) as u8,
3225            );
3226            let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3227            let cell = Cell::from_char('A')
3228                .with_fg(fg)
3229                .with_bg(bg)
3230                .with_attrs(CellAttrs::new(flags, 0));
3231            styles.push(CellStyle::from_cell(&cell));
3232        }
3233
3234        let mut presenter = test_presenter();
3235        let mut jsonl = Vec::new();
3236        let mut sink = 0u64;
3237
3238        for i in 0..iterations {
3239            let old = styles[i as usize % styles.len()];
3240            let new = styles[(i as usize + 1) % styles.len()];
3241
3242            presenter.writer.reset_counter();
3243            presenter.writer.inner_mut().get_mut().clear();
3244
3245            let start = Instant::now();
3246            presenter.emit_style_delta(old, new).unwrap();
3247            let elapsed_us = start.elapsed().as_micros() as u64;
3248            let bytes = presenter.writer.bytes_written();
3249
3250            if emit_json {
3251                writeln!(
3252                    &mut jsonl,
3253                    "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3254                )
3255                .unwrap();
3256            } else {
3257                sink = sink.wrapping_add(elapsed_us ^ bytes);
3258            }
3259        }
3260
3261        if emit_json {
3262            let text = String::from_utf8(jsonl).unwrap();
3263            let lines: Vec<&str> = text.lines().collect();
3264            assert_eq!(lines.len() as u32, iterations);
3265        } else {
3266            std::hint::black_box(sink);
3267        }
3268    }
3269
3270    #[test]
3271    fn e2e_presenter_stress_deterministic() {
3272        // Deterministic stress test: seeded style churn across multiple frames,
3273        // verifying no visual divergence via terminal model.
3274        use crate::terminal_model::TerminalModel;
3275
3276        let width = 60u16;
3277        let height = 20u16;
3278        let num_frames = 10;
3279
3280        let mut prev_buffer = Buffer::new(width, height);
3281        let mut presenter = test_presenter();
3282        let mut model = TerminalModel::new(width as usize, height as usize);
3283        let mut rng = 0x5D2E_55DE_5D42_u64;
3284        let mut next = || -> u64 {
3285            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3286            rng
3287        };
3288
3289        for _frame in 0..num_frames {
3290            // Build next frame: modify ~20% of cells each time
3291            let mut buffer = prev_buffer.clone();
3292            let changes = (width as usize * height as usize) / 5;
3293            for _ in 0..changes {
3294                let v = next();
3295                let x = (v % width as u64) as u16;
3296                let y = ((v >> 16) % height as u64) as u16;
3297                let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3298                let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3299                let cell = Cell::from_char(ch).with_fg(fg);
3300                buffer.set_raw(x, y, cell);
3301            }
3302
3303            let diff = BufferDiff::compute(&prev_buffer, &buffer);
3304            presenter.present(&buffer, &diff).unwrap();
3305
3306            prev_buffer = buffer;
3307        }
3308
3309        // Get all output and verify final frame via terminal model
3310        let output = presenter.into_inner().unwrap();
3311        model.process(&output);
3312
3313        // Verify a sampling of cells match the final buffer
3314        let mut checked = 0;
3315        for y in 0..height {
3316            for x in 0..width {
3317                let buf_cell = prev_buffer.get_unchecked(x, y);
3318                if !buf_cell.is_empty()
3319                    && let Some(model_cell) = model.cell(x as usize, y as usize)
3320                {
3321                    let expected = buf_cell.content.as_char().unwrap_or(' ');
3322                    let mut buf = [0u8; 4];
3323                    let expected_str = expected.encode_utf8(&mut buf);
3324                    if model_cell.text.as_str() == expected_str {
3325                        checked += 1;
3326                    }
3327                }
3328            }
3329        }
3330
3331        // At least 80% of non-empty cells should match (some may be
3332        // overwritten by cursor positioning sequences in the model)
3333        let total_nonempty = (0..height)
3334            .flat_map(|y| (0..width).map(move |x| (x, y)))
3335            .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3336            .count();
3337
3338        assert!(
3339            checked > total_nonempty * 80 / 100,
3340            "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3341        );
3342    }
3343
3344    #[test]
3345    fn style_state_persists_across_frames() {
3346        let mut presenter = test_presenter();
3347        let fg = PackedRgba::rgb(100, 150, 200);
3348
3349        // First frame - set style
3350        let mut buffer = Buffer::new(5, 1);
3351        buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3352        let old = Buffer::new(5, 1);
3353        let diff = BufferDiff::compute(&old, &buffer);
3354        presenter.present(&buffer, &diff).unwrap();
3355
3356        // Style should be tracked (but reset at frame end per the implementation)
3357        // After present(), current_style is None due to sgr_reset at frame end
3358        assert!(
3359            presenter.current_style.is_none(),
3360            "Style should be reset after frame end"
3361        );
3362    }
3363
3364    // =========================================================================
3365    // Edge-case tests (bd-27tya)
3366    // =========================================================================
3367
3368    // --- Cost model boundary values ---
3369
3370    #[test]
3371    fn cost_cup_zero_zero() {
3372        // CUP at (0,0) → "\x1b[1;1H" = 6 bytes
3373        assert_eq!(cost_model::cup_cost(0, 0), 6);
3374    }
3375
3376    #[test]
3377    fn cost_cup_max_max() {
3378        // CUP at (u16::MAX, u16::MAX) → "\x1b[65536;65536H"
3379        // 2 (CSI) + 5 (row digits) + 1 (;) + 5 (col digits) + 1 (H) = 14
3380        assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3381    }
3382
3383    #[test]
3384    fn cost_cha_zero() {
3385        // CHA at col 0 → "\x1b[1G" = 4 bytes
3386        assert_eq!(cost_model::cha_cost(0), 4);
3387    }
3388
3389    #[test]
3390    fn cost_cha_max() {
3391        // CHA at col u16::MAX → "\x1b[65536G" = 8 bytes
3392        assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3393    }
3394
3395    #[test]
3396    fn cost_cuf_zero_is_free() {
3397        assert_eq!(cost_model::cuf_cost(0), 0);
3398    }
3399
3400    #[test]
3401    fn cost_cuf_one_is_three() {
3402        // CUF(1) = "\x1b[C" = 3 bytes
3403        assert_eq!(cost_model::cuf_cost(1), 3);
3404    }
3405
3406    #[test]
3407    fn cost_cuf_two_has_digit() {
3408        // CUF(2) = "\x1b[2C" = 4 bytes
3409        assert_eq!(cost_model::cuf_cost(2), 4);
3410    }
3411
3412    #[test]
3413    fn cost_cuf_max() {
3414        // CUF(u16::MAX) = "\x1b[65535C" = 3 + 5 = 8 bytes
3415        assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3416    }
3417
3418    #[test]
3419    fn cost_cheapest_move_already_at_target() {
3420        assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3421    }
3422
3423    #[test]
3424    fn cost_cheapest_move_unknown_position() {
3425        // When from is unknown, can only use CUP
3426        let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3427        assert_eq!(cost, cost_model::cup_cost(3, 5));
3428    }
3429
3430    #[test]
3431    fn cost_cheapest_move_known_y_unknown_x() {
3432        // from_x=None, from_y=Some → still uses CUP
3433        let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3434        assert_eq!(cost, cost_model::cup_cost(3, 5));
3435    }
3436
3437    #[test]
3438    fn cost_cheapest_move_backward_same_row() {
3439        // Moving backward on same row: CHA or CUP, whichever is cheaper
3440        let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3441        let cha = cost_model::cha_cost(5);
3442        let cup = cost_model::cup_cost(0, 5);
3443        assert_eq!(cost, cha.min(cup));
3444    }
3445
3446    #[test]
3447    fn cost_cheapest_move_same_row_same_col() {
3448        // Same (x, y) via the (fx, fy) == (to_x, to_y) check
3449        assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3450    }
3451
3452    // --- CUP/CHA/CUF cost accuracy across digit boundaries ---
3453
3454    #[test]
3455    fn cost_cup_digit_boundaries() {
3456        let mut buf = Vec::new();
3457        for (row, col) in [
3458            (0u16, 0u16),
3459            (8, 8),
3460            (9, 9),
3461            (98, 98),
3462            (99, 99),
3463            (998, 998),
3464            (999, 999),
3465            (9998, 9998),
3466            (9999, 9999),
3467            (u16::MAX, u16::MAX),
3468        ] {
3469            buf.clear();
3470            ansi::cup(&mut buf, row, col).unwrap();
3471            assert_eq!(
3472                buf.len(),
3473                cost_model::cup_cost(row, col),
3474                "CUP cost mismatch at ({row}, {col})"
3475            );
3476        }
3477    }
3478
3479    #[test]
3480    fn cost_cha_digit_boundaries() {
3481        let mut buf = Vec::new();
3482        for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3483            buf.clear();
3484            ansi::cha(&mut buf, col).unwrap();
3485            assert_eq!(
3486                buf.len(),
3487                cost_model::cha_cost(col),
3488                "CHA cost mismatch at col {col}"
3489            );
3490        }
3491    }
3492
3493    #[test]
3494    fn cost_cuf_digit_boundaries() {
3495        let mut buf = Vec::new();
3496        for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3497            buf.clear();
3498            ansi::cuf(&mut buf, n).unwrap();
3499            assert_eq!(
3500                buf.len(),
3501                cost_model::cuf_cost(n),
3502                "CUF cost mismatch for n={n}"
3503            );
3504        }
3505    }
3506
3507    // --- RowPlan scratch reuse ---
3508
3509    #[test]
3510    fn plan_row_reuse_matches_plan_row() {
3511        let runs = [
3512            ChangeRun::new(5, 2, 4),
3513            ChangeRun::new(5, 8, 10),
3514            ChangeRun::new(5, 20, 25),
3515        ];
3516        let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3517        let mut scratch = cost_model::RowPlanScratch::default();
3518        let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3519        assert_eq!(plan1, plan2);
3520    }
3521
3522    #[test]
3523    fn plan_row_reuse_across_different_sizes() {
3524        // Use scratch with a large row first, then a small row
3525        let mut scratch = cost_model::RowPlanScratch::default();
3526
3527        let large_runs: Vec<ChangeRun> = (0..20)
3528            .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
3529            .collect();
3530        let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
3531        assert!(!plan_large.spans().is_empty());
3532
3533        let small_runs = [ChangeRun::new(1, 5, 8)];
3534        let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
3535        assert_eq!(plan_small.spans().len(), 1);
3536        assert_eq!(plan_small.spans()[0].x0, 5);
3537        assert_eq!(plan_small.spans()[0].x1, 8);
3538    }
3539
3540    // --- DP gap boundary (exactly 32 and 33 cells) ---
3541
3542    #[test]
3543    fn plan_row_gap_exactly_32_cells() {
3544        // Two runs with exactly 32-cell gap: run at 0-0 and 33-33
3545        // gap = 33 - 0 + 1 - 2 = 32 cells
3546        let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
3547        let plan = cost_model::plan_row(&runs, None, None);
3548        // 32-cell gap is at the break boundary; the DP may still consider merging
3549        // since the check is `gap_cells > 32` (strictly greater)
3550        // gap = 34 total - 2 changed = 32, which is NOT > 32, so merge is considered
3551        assert!(
3552            plan.spans().len() <= 2,
3553            "32-cell gap should still consider merge"
3554        );
3555    }
3556
3557    #[test]
3558    fn plan_row_gap_33_cells_stays_sparse() {
3559        // Two runs with 33-cell gap: run at 0-0 and 34-34
3560        // gap = 34 - 0 + 1 - 2 = 33 > 32, so merge is NOT considered
3561        let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
3562        let plan = cost_model::plan_row(&runs, None, None);
3563        assert_eq!(
3564            plan.spans().len(),
3565            2,
3566            "33-cell gap should stay sparse (gap > 32 breaks)"
3567        );
3568    }
3569
3570    // --- SmallVec spill: >4 separate spans ---
3571
3572    #[test]
3573    fn plan_row_many_sparse_spans() {
3574        // 6 runs with 34+ cell gaps between them (each gap > 32, no merging)
3575        let runs = [
3576            ChangeRun::new(0, 0, 0),
3577            ChangeRun::new(0, 40, 40),
3578            ChangeRun::new(0, 80, 80),
3579            ChangeRun::new(0, 120, 120),
3580            ChangeRun::new(0, 160, 160),
3581            ChangeRun::new(0, 200, 200),
3582        ];
3583        let plan = cost_model::plan_row(&runs, None, None);
3584        // All gaps are > 32, so no merging possible
3585        assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
3586    }
3587
3588    // --- CellStyle ---
3589
3590    #[test]
3591    fn cell_style_default_is_transparent_no_attrs() {
3592        let style = CellStyle::default();
3593        assert_eq!(style.fg, PackedRgba::TRANSPARENT);
3594        assert_eq!(style.bg, PackedRgba::TRANSPARENT);
3595        assert!(style.attrs.is_empty());
3596    }
3597
3598    #[test]
3599    fn cell_style_from_cell_captures_all() {
3600        let fg = PackedRgba::rgb(10, 20, 30);
3601        let bg = PackedRgba::rgb(40, 50, 60);
3602        let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
3603        let cell = Cell::from_char('X')
3604            .with_fg(fg)
3605            .with_bg(bg)
3606            .with_attrs(CellAttrs::new(flags, 5));
3607        let style = CellStyle::from_cell(&cell);
3608        assert_eq!(style.fg, fg);
3609        assert_eq!(style.bg, bg);
3610        assert_eq!(style.attrs, flags);
3611    }
3612
3613    #[test]
3614    fn cell_style_eq_and_clone() {
3615        let a = CellStyle {
3616            fg: PackedRgba::rgb(1, 2, 3),
3617            bg: PackedRgba::TRANSPARENT,
3618            attrs: StyleFlags::DIM,
3619        };
3620        let b = a;
3621        assert_eq!(a, b);
3622    }
3623
3624    // --- SGR length estimation ---
3625
3626    #[test]
3627    fn sgr_flags_len_empty() {
3628        assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
3629    }
3630
3631    #[test]
3632    fn sgr_flags_len_single() {
3633        // Single flag: "\x1b[1m" = 4 bytes → 3 + digits(code) + 0 separators
3634        let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
3635        assert!(len > 0);
3636        // Verify by actually emitting
3637        let mut buf = Vec::new();
3638        ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
3639        assert_eq!(len as usize, buf.len());
3640    }
3641
3642    #[test]
3643    fn sgr_flags_len_multiple() {
3644        let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
3645        let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
3646        let mut buf = Vec::new();
3647        ansi::sgr_flags(&mut buf, flags).unwrap();
3648        assert_eq!(len as usize, buf.len());
3649    }
3650
3651    #[test]
3652    fn sgr_flags_off_len_empty() {
3653        assert_eq!(
3654            Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
3655            0
3656        );
3657    }
3658
3659    #[test]
3660    fn sgr_rgb_len_matches_actual() {
3661        let color = PackedRgba::rgb(0, 0, 0);
3662        let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3663        // "\x1b[38;2;0;0;0m" = 2(CSI) + "38;2;" + "0;0;0" + "m" but sgr_rgb_len
3664        // is used for cost comparison, not exact output. Just check > 0.
3665        assert!(estimated > 0);
3666    }
3667
3668    #[test]
3669    fn sgr_rgb_len_large_values() {
3670        let color = PackedRgba::rgb(255, 255, 255);
3671        let small_color = PackedRgba::rgb(0, 0, 0);
3672        let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3673        let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
3674        // 255,255,255 has more digits than 0,0,0
3675        assert!(large_len > small_len);
3676    }
3677
3678    #[test]
3679    fn dec_len_u8_boundaries() {
3680        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
3681        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
3682        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
3683        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
3684        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
3685        assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
3686    }
3687
3688    // --- Style delta corner cases ---
3689
3690    #[test]
3691    fn sgr_delta_all_attrs_removed_at_once() {
3692        let mut presenter = test_presenter();
3693        let all_flags = StyleFlags::BOLD
3694            | StyleFlags::DIM
3695            | StyleFlags::ITALIC
3696            | StyleFlags::UNDERLINE
3697            | StyleFlags::BLINK
3698            | StyleFlags::REVERSE
3699            | StyleFlags::STRIKETHROUGH;
3700        let old = CellStyle {
3701            fg: PackedRgba::rgb(100, 100, 100),
3702            bg: PackedRgba::TRANSPARENT,
3703            attrs: all_flags,
3704        };
3705        let new = CellStyle {
3706            fg: PackedRgba::rgb(100, 100, 100),
3707            bg: PackedRgba::TRANSPARENT,
3708            attrs: StyleFlags::empty(),
3709        };
3710
3711        presenter.current_style = Some(old);
3712        presenter.emit_style_delta(old, new).unwrap();
3713        let output = presenter.into_inner().unwrap();
3714
3715        // Should either use individual off codes or fall back to full reset
3716        // Either way, output should be non-empty
3717        assert!(!output.is_empty());
3718    }
3719
3720    #[test]
3721    fn sgr_delta_fg_to_transparent() {
3722        let mut presenter = test_presenter();
3723        let old = CellStyle {
3724            fg: PackedRgba::rgb(200, 100, 50),
3725            bg: PackedRgba::TRANSPARENT,
3726            attrs: StyleFlags::empty(),
3727        };
3728        let new = CellStyle {
3729            fg: PackedRgba::TRANSPARENT,
3730            bg: PackedRgba::TRANSPARENT,
3731            attrs: StyleFlags::empty(),
3732        };
3733
3734        presenter.current_style = Some(old);
3735        presenter.emit_style_delta(old, new).unwrap();
3736        let output = presenter.into_inner().unwrap();
3737        let output_str = String::from_utf8_lossy(&output);
3738
3739        // When going to TRANSPARENT fg, the delta should emit the default fg code
3740        // or reset. Either way, output should be non-empty.
3741        assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
3742    }
3743
3744    #[test]
3745    fn sgr_delta_bg_to_transparent() {
3746        let mut presenter = test_presenter();
3747        let old = CellStyle {
3748            fg: PackedRgba::TRANSPARENT,
3749            bg: PackedRgba::rgb(30, 60, 90),
3750            attrs: StyleFlags::empty(),
3751        };
3752        let new = CellStyle {
3753            fg: PackedRgba::TRANSPARENT,
3754            bg: PackedRgba::TRANSPARENT,
3755            attrs: StyleFlags::empty(),
3756        };
3757
3758        presenter.current_style = Some(old);
3759        presenter.emit_style_delta(old, new).unwrap();
3760        let output = presenter.into_inner().unwrap();
3761        assert!(!output.is_empty(), "Should emit bg removal");
3762    }
3763
3764    #[test]
3765    fn sgr_delta_dim_removed_bold_stays() {
3766        // Reverse of the bold-dim collateral test: removing DIM while BOLD stays.
3767        // DIM off (code 22) also disables BOLD. If BOLD should remain,
3768        // the delta engine must re-enable BOLD.
3769        let mut presenter = test_presenter();
3770        let mut buffer = Buffer::new(3, 1);
3771
3772        let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
3773        let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
3774        buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
3775        buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
3776
3777        let old = Buffer::new(3, 1);
3778        let diff = BufferDiff::compute(&old, &buffer);
3779
3780        presenter.present(&buffer, &diff).unwrap();
3781        let output = get_output(presenter);
3782        let output_str = String::from_utf8_lossy(&output);
3783
3784        // Should contain dim-off (22) and then bold re-enable (1)
3785        assert!(
3786            output_str.contains("\x1b[22m"),
3787            "Expected dim-off (22) in: {output_str:?}"
3788        );
3789        assert!(
3790            output_str.contains("\x1b[1m"),
3791            "Expected bold re-enable (1) in: {output_str:?}"
3792        );
3793    }
3794
3795    #[test]
3796    fn sgr_delta_fallback_to_full_reset_when_cheaper() {
3797        // Many attrs removed + colors changed → delta is expensive, full reset is cheaper
3798        let mut presenter = test_presenter();
3799        let old = CellStyle {
3800            fg: PackedRgba::rgb(10, 20, 30),
3801            bg: PackedRgba::rgb(40, 50, 60),
3802            attrs: StyleFlags::BOLD
3803                | StyleFlags::DIM
3804                | StyleFlags::ITALIC
3805                | StyleFlags::UNDERLINE
3806                | StyleFlags::STRIKETHROUGH,
3807        };
3808        let new = CellStyle {
3809            fg: PackedRgba::TRANSPARENT,
3810            bg: PackedRgba::TRANSPARENT,
3811            attrs: StyleFlags::empty(),
3812        };
3813
3814        presenter.current_style = Some(old);
3815        presenter.emit_style_delta(old, new).unwrap();
3816        let output = presenter.into_inner().unwrap();
3817        let output_str = String::from_utf8_lossy(&output);
3818
3819        // With everything removed and going to default, full reset ("\x1b[0m") is cheapest
3820        assert!(
3821            output_str.contains("\x1b[0m"),
3822            "Expected full reset fallback: {output_str:?}"
3823        );
3824    }
3825
3826    // --- Content emission edge cases ---
3827
3828    #[test]
3829    fn emit_cell_control_char_replaced_with_fffd() {
3830        let mut presenter = test_presenter();
3831        presenter.cursor_x = Some(0);
3832        presenter.cursor_y = Some(0);
3833
3834        // Control character '\x01' has width 0, not empty, not continuation.
3835        // The zero-width-content path replaces it with U+FFFD.
3836        let cell = Cell::from_char('\x01');
3837        presenter.emit_cell(0, &cell, None, None).unwrap();
3838        let output = presenter.into_inner().unwrap();
3839        let output_str = String::from_utf8_lossy(&output);
3840
3841        // Should emit U+FFFD (replacement character), not the raw control char
3842        assert!(
3843            output_str.contains('\u{FFFD}'),
3844            "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
3845        );
3846        assert!(
3847            !output.contains(&0x01),
3848            "Raw control char should not appear"
3849        );
3850    }
3851
3852    #[test]
3853    fn emit_content_empty_cell_emits_space() {
3854        let mut presenter = test_presenter();
3855        presenter.cursor_x = Some(0);
3856        presenter.cursor_y = Some(0);
3857
3858        let cell = Cell::default();
3859        assert!(cell.is_empty());
3860        presenter.emit_cell(0, &cell, None, None).unwrap();
3861        let output = presenter.into_inner().unwrap();
3862        assert!(output.contains(&b' '), "Empty cell should emit space");
3863    }
3864
3865    #[test]
3866    fn emit_content_grapheme_sanitizes_escape_sequences() {
3867        let mut presenter = test_presenter();
3868        presenter.cursor_x = Some(0);
3869        presenter.cursor_y = Some(0);
3870
3871        let mut pool = GraphemePool::new();
3872        let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
3873        let cell = Cell::new(CellContent::from_grapheme(gid));
3874        presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
3875
3876        let output = presenter.into_inner().unwrap();
3877        let output_str = String::from_utf8_lossy(&output);
3878        assert!(
3879            output_str.contains("AB"),
3880            "sanitized grapheme should preserve visible payload"
3881        );
3882        assert!(
3883            !output_str.contains("\x1b[31m"),
3884            "raw escape sequence must not be emitted"
3885        );
3886    }
3887
3888    // --- Continuation cell cursor_x variants ---
3889
3890    #[test]
3891    fn continuation_cell_cursor_x_none() {
3892        let mut presenter = test_presenter();
3893        // cursor_x = None → defensive path, emits CUF(1) and sets cursor_x
3894        presenter.cursor_x = None;
3895        presenter.cursor_y = Some(0);
3896
3897        let cell = Cell::CONTINUATION;
3898        presenter.emit_cell(5, &cell, None, None).unwrap();
3899        let output = presenter.into_inner().unwrap();
3900
3901        // Should emit CUF(1) = "\x1b[C"
3902        assert!(
3903            output.windows(3).any(|w| w == b"\x1b[C"),
3904            "Should emit CUF(1) for continuation with unknown cursor_x"
3905        );
3906    }
3907
3908    #[test]
3909    fn continuation_cell_cursor_already_past() {
3910        let mut presenter = test_presenter();
3911        // cursor_x > cell x → cursor already advanced past, skip
3912        presenter.cursor_x = Some(10);
3913        presenter.cursor_y = Some(0);
3914
3915        let cell = Cell::CONTINUATION;
3916        presenter.emit_cell(5, &cell, None, None).unwrap();
3917        let output = presenter.into_inner().unwrap();
3918
3919        // Should produce no output (cursor already past)
3920        assert!(
3921            output.is_empty(),
3922            "Should skip continuation when cursor is past it"
3923        );
3924    }
3925
3926    // --- clear_line ---
3927
3928    #[test]
3929    fn clear_line_positions_cursor_and_erases() {
3930        let mut presenter = test_presenter();
3931        presenter.clear_line(5).unwrap();
3932        let output = get_output(presenter);
3933        let output_str = String::from_utf8_lossy(&output);
3934
3935        // Should contain CUP to row 5 col 0 and erase line
3936        assert!(
3937            output_str.contains("\x1b[2K"),
3938            "Should contain erase line sequence"
3939        );
3940    }
3941
3942    // --- into_inner ---
3943
3944    #[test]
3945    fn into_inner_returns_accumulated_output() {
3946        let mut presenter = test_presenter();
3947        presenter.position_cursor(0, 0).unwrap();
3948        let inner = presenter.into_inner().unwrap();
3949        assert!(!inner.is_empty(), "into_inner should return buffered data");
3950    }
3951
3952    // --- move_cursor_optimal edge cases ---
3953
3954    #[test]
3955    fn move_cursor_optimal_same_row_forward_large() {
3956        let mut presenter = test_presenter();
3957        presenter.cursor_x = Some(0);
3958        presenter.cursor_y = Some(0);
3959
3960        // Forward by 100 columns. CUF(100) vs CHA(100) vs CUP(0,100)
3961        presenter.move_cursor_optimal(100, 0).unwrap();
3962        let output = presenter.into_inner().unwrap();
3963
3964        // Verify the output picks the cheapest move
3965        let cuf = cost_model::cuf_cost(100);
3966        let cha = cost_model::cha_cost(100);
3967        let cup = cost_model::cup_cost(0, 100);
3968        let cheapest = cuf.min(cha).min(cup);
3969        assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
3970    }
3971
3972    #[test]
3973    fn move_cursor_optimal_same_row_backward_to_zero() {
3974        let mut presenter = test_presenter();
3975        presenter.cursor_x = Some(50);
3976        presenter.cursor_y = Some(0);
3977
3978        presenter.move_cursor_optimal(0, 0).unwrap();
3979        let output = presenter.into_inner().unwrap();
3980
3981        // CHA(0) → "\x1b[1G" = 4 bytes, CUP(0,0) = "\x1b[1;1H" = 6 bytes
3982        // CHA should win
3983        let mut expected = Vec::new();
3984        ansi::cha(&mut expected, 0).unwrap();
3985        assert_eq!(output, expected, "Should use CHA for backward to col 0");
3986    }
3987
3988    #[test]
3989    fn move_cursor_optimal_unknown_cursor_uses_cup() {
3990        let mut presenter = test_presenter();
3991        // cursor_x and cursor_y are None
3992        presenter.move_cursor_optimal(10, 5).unwrap();
3993        let output = presenter.into_inner().unwrap();
3994        let mut expected = Vec::new();
3995        ansi::cup(&mut expected, 5, 10).unwrap();
3996        assert_eq!(output, expected, "Should use CUP when cursor is unknown");
3997    }
3998
3999    // --- Present with sync: verify wrap order ---
4000
4001    #[test]
4002    fn sync_wrap_order_begin_content_reset_end() {
4003        let mut presenter = test_presenter_with_sync();
4004        let mut buffer = Buffer::new(3, 1);
4005        buffer.set_raw(0, 0, Cell::from_char('Z'));
4006
4007        let old = Buffer::new(3, 1);
4008        let diff = BufferDiff::compute(&old, &buffer);
4009
4010        presenter.present(&buffer, &diff).unwrap();
4011        let output = get_output(presenter);
4012
4013        let sync_begin_pos = output
4014            .windows(ansi::SYNC_BEGIN.len())
4015            .position(|w| w == ansi::SYNC_BEGIN)
4016            .expect("sync begin missing");
4017        let z_pos = output
4018            .iter()
4019            .position(|&b| b == b'Z')
4020            .expect("character Z missing");
4021        let reset_pos = output
4022            .windows(b"\x1b[0m".len())
4023            .rposition(|w| w == b"\x1b[0m")
4024            .expect("SGR reset missing");
4025        let sync_end_pos = output
4026            .windows(ansi::SYNC_END.len())
4027            .rposition(|w| w == ansi::SYNC_END)
4028            .expect("sync end missing");
4029
4030        assert!(sync_begin_pos < z_pos, "sync begin before content");
4031        assert!(z_pos < reset_pos, "content before reset");
4032        assert!(reset_pos < sync_end_pos, "reset before sync end");
4033    }
4034
4035    // --- Multi-frame style state ---
4036
4037    #[test]
4038    fn style_none_after_each_frame() {
4039        let mut presenter = test_presenter();
4040        let fg = PackedRgba::rgb(255, 128, 64);
4041
4042        for _ in 0..5 {
4043            let mut buffer = Buffer::new(3, 1);
4044            buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4045            let old = Buffer::new(3, 1);
4046            let diff = BufferDiff::compute(&old, &buffer);
4047            presenter.present(&buffer, &diff).unwrap();
4048
4049            // After each present(), current_style should be None (reset at frame end)
4050            assert!(
4051                presenter.current_style.is_none(),
4052                "Style should be None after frame end"
4053            );
4054            assert!(
4055                presenter.current_link.is_none(),
4056                "Link should be None after frame end"
4057            );
4058        }
4059    }
4060
4061    // --- Link state after present with open link ---
4062
4063    #[test]
4064    fn link_closed_at_frame_end_even_if_all_cells_linked() {
4065        let mut presenter = test_presenter();
4066        let mut buffer = Buffer::new(3, 1);
4067        let mut links = LinkRegistry::new();
4068        let link_id = links.register("https://all-linked.test");
4069
4070        // All cells have the same link
4071        for x in 0..3 {
4072            buffer.set_raw(
4073                x,
4074                0,
4075                Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4076            );
4077        }
4078
4079        let old = Buffer::new(3, 1);
4080        let diff = BufferDiff::compute(&old, &buffer);
4081        presenter
4082            .present_with_pool(&buffer, &diff, None, Some(&links))
4083            .unwrap();
4084
4085        // After present, current_link must be None (closed at frame end)
4086        assert!(
4087            presenter.current_link.is_none(),
4088            "Link must be closed at frame end"
4089        );
4090    }
4091
4092    // --- PresentStats ---
4093
4094    #[test]
4095    fn present_stats_empty_diff() {
4096        let mut presenter = test_presenter();
4097        let buffer = Buffer::new(10, 10);
4098        let diff = BufferDiff::new();
4099        let stats = presenter.present(&buffer, &diff).unwrap();
4100
4101        assert_eq!(stats.cells_changed, 0);
4102        assert_eq!(stats.run_count, 0);
4103        // bytes_emitted includes the SGR reset
4104        assert!(stats.bytes_emitted > 0);
4105    }
4106
4107    #[test]
4108    fn present_stats_full_row() {
4109        let mut presenter = test_presenter();
4110        let mut buffer = Buffer::new(10, 1);
4111        for x in 0..10 {
4112            buffer.set_raw(x, 0, Cell::from_char('A'));
4113        }
4114        let old = Buffer::new(10, 1);
4115        let diff = BufferDiff::compute(&old, &buffer);
4116        let stats = presenter.present(&buffer, &diff).unwrap();
4117
4118        assert_eq!(stats.cells_changed, 10);
4119        assert!(stats.run_count >= 1);
4120        assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4121    }
4122
4123    // --- Capabilities accessor ---
4124
4125    #[test]
4126    fn capabilities_accessor() {
4127        let mut caps = TerminalCapabilities::basic();
4128        caps.sync_output = true;
4129        let presenter = Presenter::new(Vec::<u8>::new(), caps);
4130        assert!(presenter.capabilities().sync_output);
4131    }
4132
4133    // --- Flush ---
4134
4135    #[test]
4136    fn flush_succeeds_on_empty_presenter() {
4137        let mut presenter = test_presenter();
4138        presenter.flush().unwrap();
4139        let output = get_output(presenter);
4140        assert!(output.is_empty());
4141    }
4142
4143    // --- RowPlan total_cost ---
4144
4145    #[test]
4146    fn row_plan_total_cost_matches_dp() {
4147        let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4148        let plan = cost_model::plan_row(&runs, None, None);
4149        assert!(plan.total_cost() > 0);
4150        // The total cost includes move costs + cell costs
4151        // Just verify it's consistent (non-zero) and accessible
4152    }
4153
4154    // --- Style delta: same attrs, only colors change (hot path) ---
4155
4156    #[test]
4157    fn sgr_delta_hot_path_only_fg_change() {
4158        let mut presenter = test_presenter();
4159        let old = CellStyle {
4160            fg: PackedRgba::rgb(255, 0, 0),
4161            bg: PackedRgba::rgb(0, 0, 0),
4162            attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4163        };
4164        let new = CellStyle {
4165            fg: PackedRgba::rgb(0, 255, 0),
4166            bg: PackedRgba::rgb(0, 0, 0),
4167            attrs: StyleFlags::BOLD | StyleFlags::ITALIC, // same attrs
4168        };
4169
4170        presenter.current_style = Some(old);
4171        presenter.emit_style_delta(old, new).unwrap();
4172        let output = presenter.into_inner().unwrap();
4173        let output_str = String::from_utf8_lossy(&output);
4174
4175        // Only fg should change, no reset
4176        assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4177        assert!(
4178            !output_str.contains("\x1b[0m"),
4179            "No reset needed for color-only change"
4180        );
4181        // Should NOT re-emit attrs
4182        assert!(
4183            !output_str.contains("\x1b[1m"),
4184            "Bold should not be re-emitted"
4185        );
4186    }
4187
4188    #[test]
4189    fn sgr_delta_hot_path_both_colors_change() {
4190        let mut presenter = test_presenter();
4191        let old = CellStyle {
4192            fg: PackedRgba::rgb(1, 2, 3),
4193            bg: PackedRgba::rgb(4, 5, 6),
4194            attrs: StyleFlags::UNDERLINE,
4195        };
4196        let new = CellStyle {
4197            fg: PackedRgba::rgb(7, 8, 9),
4198            bg: PackedRgba::rgb(10, 11, 12),
4199            attrs: StyleFlags::UNDERLINE, // same
4200        };
4201
4202        presenter.current_style = Some(old);
4203        presenter.emit_style_delta(old, new).unwrap();
4204        let output = presenter.into_inner().unwrap();
4205        let output_str = String::from_utf8_lossy(&output);
4206
4207        assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4208        assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4209        assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4210    }
4211
4212    // --- Style full apply ---
4213
4214    #[test]
4215    fn emit_style_full_default_is_just_reset() {
4216        let mut presenter = test_presenter();
4217        let default_style = CellStyle::default();
4218        presenter.emit_style_full(default_style).unwrap();
4219        let output = presenter.into_inner().unwrap();
4220
4221        // Default style (transparent fg/bg, no attrs) should just be reset
4222        assert_eq!(output, b"\x1b[0m");
4223    }
4224
4225    #[test]
4226    fn emit_style_full_with_all_properties() {
4227        let mut presenter = test_presenter();
4228        let style = CellStyle {
4229            fg: PackedRgba::rgb(10, 20, 30),
4230            bg: PackedRgba::rgb(40, 50, 60),
4231            attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4232        };
4233        presenter.emit_style_full(style).unwrap();
4234        let output = presenter.into_inner().unwrap();
4235        let output_str = String::from_utf8_lossy(&output);
4236
4237        // Should have reset + fg + bg + attrs
4238        assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4239        assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4240        assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4241    }
4242
4243    // --- Multiple rows with different strategies ---
4244
4245    #[test]
4246    fn present_multiple_rows_different_strategies() {
4247        let mut presenter = test_presenter();
4248        let mut buffer = Buffer::new(80, 5);
4249
4250        // Row 0: dense changes (should merge)
4251        for x in (0..20).step_by(2) {
4252            buffer.set_raw(x, 0, Cell::from_char('D'));
4253        }
4254        // Row 2: sparse changes (large gap, should stay sparse)
4255        buffer.set_raw(0, 2, Cell::from_char('L'));
4256        buffer.set_raw(79, 2, Cell::from_char('R'));
4257        // Row 4: single cell
4258        buffer.set_raw(40, 4, Cell::from_char('M'));
4259
4260        let old = Buffer::new(80, 5);
4261        let diff = BufferDiff::compute(&old, &buffer);
4262        presenter.present(&buffer, &diff).unwrap();
4263        let output = get_output(presenter);
4264        let output_str = String::from_utf8_lossy(&output);
4265
4266        assert!(output_str.contains('D'));
4267        assert!(output_str.contains('L'));
4268        assert!(output_str.contains('R'));
4269        assert!(output_str.contains('M'));
4270    }
4271
4272    #[test]
4273    fn zero_width_chars_replaced_with_placeholder() {
4274        let mut presenter = test_presenter();
4275        let mut buffer = Buffer::new(5, 1);
4276
4277        // U+0301 is COMBINING ACUTE ACCENT (width 0).
4278        // It is not empty, not continuation, not grapheme (unless pooled).
4279        // Storing it directly as a char means it's a standalone cell content.
4280        let zw_char = '\u{0301}';
4281
4282        // Ensure our assumption about width is correct for this environment
4283        assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4284
4285        buffer.set_raw(0, 0, Cell::from_char(zw_char));
4286        buffer.set_raw(1, 0, Cell::from_char('A'));
4287
4288        let old = Buffer::new(5, 1);
4289        let diff = BufferDiff::compute(&old, &buffer);
4290
4291        presenter.present(&buffer, &diff).unwrap();
4292        let output = get_output(presenter);
4293        let output_str = String::from_utf8_lossy(&output);
4294
4295        // Should contain U+FFFD (Replacement Character)
4296        assert!(
4297            output_str.contains("\u{FFFD}"),
4298            "Expected replacement character for zero-width content, got: {:?}",
4299            output_str
4300        );
4301
4302        // Should NOT contain the raw combining mark
4303        assert!(
4304            !output_str.contains(zw_char),
4305            "Should not contain raw zero-width char"
4306        );
4307
4308        // Should contain 'A' (verify cursor sync didn't swallow it)
4309        assert!(
4310            output_str.contains('A'),
4311            "Should contain subsequent character 'A'"
4312        );
4313    }
4314}
4315
4316#[cfg(test)]
4317mod proptests {
4318    use super::*;
4319    use crate::cell::{Cell, PackedRgba};
4320    use crate::diff::BufferDiff;
4321    use crate::terminal_model::TerminalModel;
4322    use proptest::prelude::*;
4323
4324    /// Create a presenter for testing.
4325    fn test_presenter() -> Presenter<Vec<u8>> {
4326        let caps = TerminalCapabilities::basic();
4327        Presenter::new(Vec::new(), caps)
4328    }
4329
4330    proptest! {
4331        /// Property: Presenter output, when applied to terminal model, produces
4332        /// the correct characters for changed cells.
4333        #[test]
4334        fn presenter_roundtrip_characters(
4335            width in 5u16..40,
4336            height in 3u16..20,
4337            num_chars in 1usize..50, // At least 1 char to have meaningful diff
4338        ) {
4339            let mut buffer = Buffer::new(width, height);
4340            let mut changed_positions = std::collections::HashSet::new();
4341
4342            // Fill some cells with ASCII chars
4343            for i in 0..num_chars {
4344                let x = (i * 7 + 3) as u16 % width;
4345                let y = (i * 11 + 5) as u16 % height;
4346                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4347                buffer.set_raw(x, y, Cell::from_char(ch));
4348                changed_positions.insert((x, y));
4349            }
4350
4351            // Present full buffer
4352            let mut presenter = test_presenter();
4353            let old = Buffer::new(width, height);
4354            let diff = BufferDiff::compute(&old, &buffer);
4355            presenter.present(&buffer, &diff).unwrap();
4356            let output = presenter.into_inner().unwrap();
4357
4358            // Apply to terminal model
4359            let mut model = TerminalModel::new(width as usize, height as usize);
4360            model.process(&output);
4361
4362            // Verify ONLY changed characters match (model may have different default)
4363            for &(x, y) in &changed_positions {
4364                let buf_cell = buffer.get_unchecked(x, y);
4365                let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4366                let mut expected_buf = [0u8; 4];
4367                let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4368
4369                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4370                    prop_assert_eq!(
4371                        model_cell.text.as_str(),
4372                        expected_str,
4373                        "Character mismatch at ({}, {})", x, y
4374                    );
4375                }
4376            }
4377        }
4378
4379        /// Property: After complete frame presentation, SGR is reset.
4380        #[test]
4381        fn style_reset_after_present(
4382            width in 5u16..30,
4383            height in 3u16..15,
4384            num_styled in 1usize..20,
4385        ) {
4386            let mut buffer = Buffer::new(width, height);
4387
4388            // Add some styled cells
4389            for i in 0..num_styled {
4390                let x = (i * 7) as u16 % width;
4391                let y = (i * 11) as u16 % height;
4392                let fg = PackedRgba::rgb(
4393                    ((i * 31) % 256) as u8,
4394                    ((i * 47) % 256) as u8,
4395                    ((i * 71) % 256) as u8,
4396                );
4397                buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4398            }
4399
4400            // Present
4401            let mut presenter = test_presenter();
4402            let old = Buffer::new(width, height);
4403            let diff = BufferDiff::compute(&old, &buffer);
4404            presenter.present(&buffer, &diff).unwrap();
4405            let output = presenter.into_inner().unwrap();
4406            let output_str = String::from_utf8_lossy(&output);
4407
4408            // Output should end with SGR reset sequence
4409            prop_assert!(
4410                output_str.contains("\x1b[0m"),
4411                "Output should contain SGR reset"
4412            );
4413        }
4414
4415        /// Property: Presenter handles empty diff correctly.
4416        #[test]
4417        fn empty_diff_minimal_output(
4418            width in 5u16..50,
4419            height in 3u16..25,
4420        ) {
4421            let buffer = Buffer::new(width, height);
4422            let diff = BufferDiff::new(); // Empty diff
4423
4424            let mut presenter = test_presenter();
4425            presenter.present(&buffer, &diff).unwrap();
4426            let output = presenter.into_inner().unwrap();
4427
4428            // Output should only be SGR reset (or very minimal)
4429            // No cursor moves or cell content for empty diff
4430            prop_assert!(output.len() < 50, "Empty diff should have minimal output");
4431        }
4432
4433        /// Property: Full buffer change produces diff with all cells.
4434        ///
4435        /// When every cell differs, the diff should contain exactly
4436        /// width * height changes.
4437        #[test]
4438        fn diff_size_bounds(
4439            width in 5u16..30,
4440            height in 3u16..15,
4441        ) {
4442            // Full change buffer
4443            let old = Buffer::new(width, height);
4444            let mut new = Buffer::new(width, height);
4445
4446            for y in 0..height {
4447                for x in 0..width {
4448                    new.set_raw(x, y, Cell::from_char('X'));
4449                }
4450            }
4451
4452            let diff = BufferDiff::compute(&old, &new);
4453
4454            // Diff should capture all cells
4455            prop_assert_eq!(
4456                diff.len(),
4457                (width as usize) * (height as usize),
4458                "Full change should have all cells in diff"
4459            );
4460        }
4461
4462        /// Property: Presenter cursor state is consistent after operations.
4463        #[test]
4464        fn presenter_cursor_consistency(
4465            width in 10u16..40,
4466            height in 5u16..20,
4467            num_runs in 1usize..10,
4468        ) {
4469            let mut buffer = Buffer::new(width, height);
4470
4471            // Create some runs of changes
4472            for i in 0..num_runs {
4473                let start_x = (i * 5) as u16 % (width - 5);
4474                let y = i as u16 % height;
4475                for x in start_x..(start_x + 3) {
4476                    buffer.set_raw(x, y, Cell::from_char('A'));
4477                }
4478            }
4479
4480            // Multiple presents should work correctly
4481            let mut presenter = test_presenter();
4482            let old = Buffer::new(width, height);
4483
4484            for _ in 0..3 {
4485                let diff = BufferDiff::compute(&old, &buffer);
4486                presenter.present(&buffer, &diff).unwrap();
4487            }
4488
4489            // Should not panic and produce valid output
4490            let output = presenter.into_inner().unwrap();
4491            prop_assert!(!output.is_empty(), "Should produce some output");
4492        }
4493
4494        /// Property (bd-4kq0.2.1): SGR delta produces identical visual styling
4495        /// as reset+apply for random style transitions. Verified via terminal
4496        /// model roundtrip.
4497        #[test]
4498        fn sgr_delta_transition_equivalence(
4499            width in 5u16..20,
4500            height in 3u16..10,
4501            num_styled in 2usize..15,
4502        ) {
4503            let mut buffer = Buffer::new(width, height);
4504            // Track final character at each position (later writes overwrite earlier)
4505            let mut expected: std::collections::HashMap<(u16, u16), char> =
4506                std::collections::HashMap::new();
4507
4508            // Create cells with varying styles to exercise delta engine
4509            for i in 0..num_styled {
4510                let x = (i * 3 + 1) as u16 % width;
4511                let y = (i * 5 + 2) as u16 % height;
4512                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4513                let fg = PackedRgba::rgb(
4514                    ((i * 73) % 256) as u8,
4515                    ((i * 137) % 256) as u8,
4516                    ((i * 41) % 256) as u8,
4517                );
4518                let bg = if i % 3 == 0 {
4519                    PackedRgba::rgb(
4520                        ((i * 29) % 256) as u8,
4521                        ((i * 53) % 256) as u8,
4522                        ((i * 97) % 256) as u8,
4523                    )
4524                } else {
4525                    PackedRgba::TRANSPARENT
4526                };
4527                let flags_bits = ((i * 37) % 256) as u8;
4528                let flags = StyleFlags::from_bits_truncate(flags_bits);
4529                let cell = Cell::from_char(ch)
4530                    .with_fg(fg)
4531                    .with_bg(bg)
4532                    .with_attrs(CellAttrs::new(flags, 0));
4533                buffer.set_raw(x, y, cell);
4534                expected.insert((x, y), ch);
4535            }
4536
4537            // Present with delta engine
4538            let mut presenter = test_presenter();
4539            let old = Buffer::new(width, height);
4540            let diff = BufferDiff::compute(&old, &buffer);
4541            presenter.present(&buffer, &diff).unwrap();
4542            let output = presenter.into_inner().unwrap();
4543
4544            // Apply to terminal model and verify characters
4545            let mut model = TerminalModel::new(width as usize, height as usize);
4546            model.process(&output);
4547
4548            for (&(x, y), &ch) in &expected {
4549                let mut buf = [0u8; 4];
4550                let expected_str = ch.encode_utf8(&mut buf);
4551
4552                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4553                    prop_assert_eq!(
4554                        model_cell.text.as_str(),
4555                        expected_str,
4556                        "Character mismatch at ({}, {}) with delta engine", x, y
4557                    );
4558                }
4559            }
4560        }
4561
4562        /// Property (bd-4kq0.2.2): DP cost model produces correct output
4563        /// regardless of which row strategy is chosen (sparse vs merged).
4564        /// Verified via terminal model roundtrip with scattered runs.
4565        #[test]
4566        fn dp_emit_equivalence(
4567            width in 20u16..60,
4568            height in 5u16..15,
4569            num_changes in 5usize..30,
4570        ) {
4571            let mut buffer = Buffer::new(width, height);
4572            let mut expected: std::collections::HashMap<(u16, u16), char> =
4573                std::collections::HashMap::new();
4574
4575            // Create scattered changes that will trigger both sparse and merged strategies
4576            for i in 0..num_changes {
4577                let x = (i * 7 + 3) as u16 % width;
4578                let y = (i * 3 + 1) as u16 % height;
4579                let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4580                buffer.set_raw(x, y, Cell::from_char(ch));
4581                expected.insert((x, y), ch);
4582            }
4583
4584            // Present with DP cost model
4585            let mut presenter = test_presenter();
4586            let old = Buffer::new(width, height);
4587            let diff = BufferDiff::compute(&old, &buffer);
4588            presenter.present(&buffer, &diff).unwrap();
4589            let output = presenter.into_inner().unwrap();
4590
4591            // Apply to terminal model and verify all characters are correct
4592            let mut model = TerminalModel::new(width as usize, height as usize);
4593            model.process(&output);
4594
4595            for (&(x, y), &ch) in &expected {
4596                let mut buf = [0u8; 4];
4597                let expected_str = ch.encode_utf8(&mut buf);
4598
4599                if let Some(model_cell) = model.cell(x as usize, y as usize) {
4600                    prop_assert_eq!(
4601                        model_cell.text.as_str(),
4602                        expected_str,
4603                        "DP cost model: character mismatch at ({}, {})", x, y
4604                    );
4605                }
4606            }
4607        }
4608    }
4609}