Skip to main content

ftui_render/
presenter.rs

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