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