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