Skip to main content

slt/
test_utils.rs

1//! Headless testing utilities.
2//!
3//! [`TestBackend`] renders a UI closure to an in-memory buffer without a real
4//! terminal. [`EventBuilder`] constructs event sequences for simulating user
5//! input. Together they enable snapshot and assertion-based UI testing.
6
7use crate::buffer::Buffer;
8use crate::context::Context;
9use crate::event::{
10    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKey, MouseButton, MouseEvent,
11    MouseKind,
12};
13use crate::rect::Rect;
14use crate::style::Style;
15use crate::{run_frame_kernel, FrameState, RunConfig};
16
17/// Builder for constructing a sequence of input [`Event`]s.
18///
19/// Chain calls to [`key`](EventBuilder::key), [`click`](EventBuilder::click),
20/// [`scroll_up`](EventBuilder::scroll_up), etc., then call
21/// [`build`](EventBuilder::build) to get the final `Vec<Event>`.
22///
23/// # Example
24///
25/// ```
26/// use slt::EventBuilder;
27/// use slt::KeyCode;
28///
29/// let events = EventBuilder::new()
30///     .key('a')
31///     .key_code(KeyCode::Enter)
32///     .build();
33/// assert_eq!(events.len(), 2);
34/// ```
35pub struct EventBuilder {
36    events: Vec<Event>,
37}
38
39impl EventBuilder {
40    /// Create an empty event builder.
41    pub fn new() -> Self {
42        Self { events: Vec::new() }
43    }
44
45    /// Append a character key-press event.
46    pub fn key(mut self, c: char) -> Self {
47        self.events.push(Event::Key(KeyEvent {
48            code: KeyCode::Char(c),
49            modifiers: KeyModifiers::NONE,
50            kind: KeyEventKind::Press,
51        }));
52        self
53    }
54
55    /// Append a special key-press event (arrows, Enter, Esc, etc.).
56    pub fn key_code(mut self, code: KeyCode) -> Self {
57        self.events.push(Event::Key(KeyEvent {
58            code,
59            modifiers: KeyModifiers::NONE,
60            kind: KeyEventKind::Press,
61        }));
62        self
63    }
64
65    /// Append a key-press event with modifier keys (Ctrl, Shift, Alt).
66    pub fn key_with(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
67        self.events.push(Event::Key(KeyEvent {
68            code,
69            modifiers,
70            kind: KeyEventKind::Press,
71        }));
72        self
73    }
74
75    /// Append a modifier-only key-press event (a bare Ctrl/Shift/Alt/Super
76    /// press with no accompanying character).
77    ///
78    /// Mirrors what the Kitty keyboard protocol delivers when
79    /// [`RunConfig::report_all_keys(true)`](crate::RunConfig::report_all_keys)
80    /// is enabled, so widget tests can simulate modifier-only presses without
81    /// poking crossterm. The event carries [`KeyCode::Modifier`] with
82    /// [`KeyModifiers::NONE`].
83    ///
84    /// Since 0.21.0.
85    ///
86    /// # Example
87    ///
88    /// ```
89    /// use slt::{EventBuilder, KeyCode, ModifierKey};
90    ///
91    /// let events = EventBuilder::new()
92    ///     .key_modifier(ModifierKey::LeftCtrl)
93    ///     .build();
94    /// assert_eq!(events.len(), 1);
95    /// ```
96    pub fn key_modifier(mut self, m: ModifierKey) -> Self {
97        self.events.push(Event::Key(KeyEvent {
98            code: KeyCode::Modifier(m),
99            modifiers: KeyModifiers::NONE,
100            kind: KeyEventKind::Press,
101        }));
102        self
103    }
104
105    /// Append a left mouse click at terminal position `(x, y)`.
106    pub fn click(mut self, x: u32, y: u32) -> Self {
107        self.events.push(Event::Mouse(MouseEvent {
108            kind: MouseKind::Down(MouseButton::Left),
109            x,
110            y,
111            modifiers: KeyModifiers::NONE,
112            pixel_x: None,
113            pixel_y: None,
114        }));
115        self
116    }
117
118    /// Append a left-button press at `(x, y)` carrying the given modifiers.
119    ///
120    /// Use this to simulate `Shift`+click (e.g. range extension in the
121    /// calendar widget). The plain [`click`](EventBuilder::click) helper
122    /// always sends `KeyModifiers::NONE`.
123    pub fn click_with(mut self, x: u32, y: u32, modifiers: KeyModifiers) -> Self {
124        self.events.push(Event::Mouse(MouseEvent {
125            kind: MouseKind::Down(MouseButton::Left),
126            x,
127            y,
128            modifiers,
129            pixel_x: None,
130            pixel_y: None,
131        }));
132        self
133    }
134
135    /// Append a left mouse button release at terminal position `(x, y)`.
136    pub fn mouse_up(mut self, x: u32, y: u32) -> Self {
137        self.events.push(Event::mouse_up(x, y));
138        self
139    }
140
141    /// Append a mouse drag (movement with the left button held) at `(x, y)`.
142    pub fn drag(mut self, x: u32, y: u32) -> Self {
143        self.events.push(Event::mouse_drag(x, y));
144        self
145    }
146
147    /// Append a key-release event for character `c`.
148    ///
149    /// Only meaningful on terminals that emit release events
150    /// (e.g. with the Kitty keyboard protocol enabled).
151    pub fn key_release(mut self, c: char) -> Self {
152        self.events.push(Event::key_release(c));
153        self
154    }
155
156    /// Append a terminal focus-gained event.
157    pub fn focus_gained(mut self) -> Self {
158        self.events.push(Event::FocusGained);
159        self
160    }
161
162    /// Append a terminal focus-lost event.
163    pub fn focus_lost(mut self) -> Self {
164        self.events.push(Event::FocusLost);
165        self
166    }
167
168    /// Append a scroll-up event at `(x, y)`.
169    pub fn scroll_up(mut self, x: u32, y: u32) -> Self {
170        self.events.push(Event::Mouse(MouseEvent {
171            kind: MouseKind::ScrollUp,
172            x,
173            y,
174            modifiers: KeyModifiers::NONE,
175            pixel_x: None,
176            pixel_y: None,
177        }));
178        self
179    }
180
181    /// Append a scroll-down event at `(x, y)`.
182    pub fn scroll_down(mut self, x: u32, y: u32) -> Self {
183        self.events.push(Event::Mouse(MouseEvent {
184            kind: MouseKind::ScrollDown,
185            x,
186            y,
187            modifiers: KeyModifiers::NONE,
188            pixel_x: None,
189            pixel_y: None,
190        }));
191        self
192    }
193
194    /// Append a bracketed-paste event.
195    pub fn paste(mut self, text: impl Into<String>) -> Self {
196        self.events.push(Event::Paste(text.into()));
197        self
198    }
199
200    /// Append a terminal resize event.
201    pub fn resize(mut self, width: u32, height: u32) -> Self {
202        self.events.push(Event::Resize(width, height));
203        self
204    }
205
206    /// Consume the builder and return the event sequence.
207    pub fn build(self) -> Vec<Event> {
208        self.events
209    }
210}
211
212impl Default for EventBuilder {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// Headless rendering backend for tests.
219///
220/// Renders a UI closure to an in-memory [`Buffer`] without a real terminal.
221/// Use [`render`](TestBackend::render) to run one frame, then inspect the
222/// output with [`line`](TestBackend::line), [`assert_contains`](TestBackend::assert_contains),
223/// or [`to_string_trimmed`](TestBackend::to_string_trimmed).
224/// Session state persists across renders, so multi-frame tests can exercise
225/// hooks, focus, and previous-frame hit testing.
226///
227/// # Example
228///
229/// ```
230/// use slt::TestBackend;
231///
232/// let mut backend = TestBackend::new(40, 10);
233/// backend.render(|ui| {
234///     ui.text("hello");
235/// });
236/// backend.assert_contains("hello");
237/// ```
238pub struct TestBackend {
239    buffer: Buffer,
240    width: u32,
241    height: u32,
242    frame_state: FrameState,
243    /// Frame history. `None` = recording disabled (zero overhead).
244    /// `Some(_)` = recording enabled — every [`render`](TestBackend::render)
245    /// call appends a [`FrameRecord`].
246    frames: Option<Vec<FrameRecord>>,
247}
248
249/// Snapshot of a single rendered frame, captured by
250/// [`TestBackend::record_frames`].
251///
252/// Stores the styled snapshot string (via [`Buffer::snapshot_format`]) plus a
253/// per-row trimmed text view for ergonomic substring assertions. Both are
254/// produced from the same buffer and are guaranteed to refer to the same
255/// frame.
256///
257/// Cheap to clone; useful for replaying a failing test by inspecting
258/// intermediate frames.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct FrameRecord {
261    /// Styled snapshot of the buffer at this frame, in the stable
262    /// [`Buffer::snapshot_format`] vocabulary.
263    pub snapshot: String,
264    /// Plain-text view of each buffer row, trailing spaces trimmed.
265    /// Mirrors [`TestBackend::line`] for every row.
266    pub lines: Vec<String>,
267}
268
269impl FrameRecord {
270    /// Return the frame as a multi-line string (rows joined with `\n`,
271    /// trailing empty rows preserved). Mirrors [`TestBackend::to_string_trimmed`]
272    /// on the originating buffer.
273    pub fn to_string_trimmed(&self) -> String {
274        let mut lines = self.lines.clone();
275        while lines.last().is_some_and(|l| l.is_empty()) {
276            lines.pop();
277        }
278        lines.join("\n")
279    }
280
281    /// Return the trimmed text of row `y` from this frame, or empty if `y`
282    /// is past the buffer height.
283    pub fn line(&self, y: u32) -> &str {
284        self.lines
285            .get(y as usize)
286            .map(|s| s.as_str())
287            .unwrap_or_default()
288    }
289
290    /// Assert any row in this frame contains `expected`. Panics with a
291    /// row-by-row dump on failure.
292    pub fn assert_contains(&self, expected: &str) {
293        for line in &self.lines {
294            if line.contains(expected) {
295                return;
296            }
297        }
298        let mut detail = String::new();
299        for (y, line) in self.lines.iter().enumerate() {
300            detail.push_str(&format!("  {y}: {line}\n"));
301        }
302        panic!("FrameRecord does not contain {expected:?}.\nFrame:\n{detail}");
303    }
304}
305
306impl TestBackend {
307    /// Create a test backend with the given terminal dimensions.
308    pub fn new(width: u32, height: u32) -> Self {
309        let area = Rect::new(0, 0, width, height);
310        Self {
311            buffer: Buffer::empty(area),
312            width,
313            height,
314            frame_state: FrameState::default(),
315            frames: None,
316        }
317    }
318
319    /// Enable frame recording.
320    ///
321    /// After this call, every subsequent [`render`](TestBackend::render),
322    /// [`render_with_events`](TestBackend::render_with_events), and
323    /// [`run_with_events`](TestBackend::run_with_events) call appends a
324    /// [`FrameRecord`] to the internal history. Disabled by default so tests
325    /// that don't need history pay zero memory overhead.
326    ///
327    /// Returns `self` for chaining.
328    ///
329    /// # Example
330    ///
331    /// ```
332    /// use slt::TestBackend;
333    ///
334    /// let mut tb = TestBackend::new(20, 3).record_frames();
335    /// for n in 0..3 {
336    ///     tb.render(|ui| {
337    ///         ui.text(format!("frame {n}"));
338    ///     });
339    /// }
340    /// assert_eq!(tb.frames().len(), 3);
341    /// tb.frames()[0].assert_contains("frame 0");
342    /// tb.frames()[2].assert_contains("frame 2");
343    /// ```
344    pub fn record_frames(mut self) -> Self {
345        if self.frames.is_none() {
346            self.frames = Some(Vec::new());
347        }
348        self
349    }
350
351    /// Return all captured frame snapshots in chronological order.
352    ///
353    /// Returns an empty slice if [`record_frames`](TestBackend::record_frames)
354    /// was never called on this backend.
355    pub fn frames(&self) -> &[FrameRecord] {
356        self.frames.as_deref().unwrap_or(&[])
357    }
358
359    /// Capture the current buffer state into the recording, if enabled.
360    ///
361    /// No-op when recording is off — keeps the hot path allocation-free
362    /// for the common case.
363    fn capture_frame(&mut self) {
364        if let Some(frames) = self.frames.as_mut() {
365            let snapshot = self.buffer.snapshot_format();
366            let mut lines = Vec::with_capacity(self.height as usize);
367            for y in 0..self.height {
368                let mut s = String::new();
369                for x in 0..self.width {
370                    s.push_str(&self.buffer.get(x, y).symbol);
371                }
372                lines.push(s.trim_end().to_string());
373            }
374            frames.push(FrameRecord { snapshot, lines });
375        }
376    }
377
378    fn render_frame(
379        &mut self,
380        events: Vec<Event>,
381        setup_state: impl FnOnce(&mut FrameState),
382        f: impl FnOnce(&mut Context),
383    ) {
384        setup_state(&mut self.frame_state);
385
386        self.buffer.reset();
387        let mut once = Some(f);
388        let mut render = |ui: &mut Context| {
389            if let Some(f) = once.take() {
390                f(ui);
391            } else {
392                panic!("render closure called twice");
393            }
394        };
395        let _ = run_frame_kernel(
396            &mut self.buffer,
397            &mut self.frame_state,
398            &RunConfig::default(),
399            (self.width, self.height),
400            events,
401            false,
402            &mut render,
403        );
404        self.capture_frame();
405    }
406
407    /// Run a UI closure for one frame and render to the internal buffer.
408    pub fn render(&mut self, f: impl FnOnce(&mut Context)) {
409        self.render_frame(Vec::new(), |_| {}, f);
410    }
411
412    /// Render with injected events and focus state for interaction testing.
413    pub fn render_with_events(
414        &mut self,
415        events: Vec<Event>,
416        focus_index: usize,
417        prev_focus_count: usize,
418        f: impl FnOnce(&mut Context),
419    ) {
420        self.render_frame(
421            events,
422            |state| {
423                state.focus.focus_index = focus_index;
424                state.focus.prev_focus_count = prev_focus_count;
425            },
426            f,
427        );
428    }
429
430    /// Convenience wrapper: render with events using default focus state.
431    pub fn run_with_events(&mut self, events: Vec<Event>, f: impl FnOnce(&mut crate::Context)) {
432        self.render_with_events(events, 0, 0, f);
433    }
434
435    /// Number of live frame-clock scheduler timer slots persisted after the
436    /// most recent render (issue #248). Test-only — used to assert that
437    /// abandoned timers are garbage-collected and `SchedulerState` does not
438    /// grow without bound.
439    #[cfg(test)]
440    pub(crate) fn scheduler_slot_count(&self) -> usize {
441        self.frame_state.scheduler.slot_count()
442    }
443
444    /// Inject the ambient Tokio runtime handle so `Context::spawn` works inside
445    /// rendered frames (issue #234). Mirrors what `run_async_loop` does once
446    /// before its loop; test-only — real async runs go through `run_async`.
447    #[cfg(all(test, feature = "async"))]
448    pub(crate) fn set_async_runtime(&mut self, handle: tokio::runtime::Handle) {
449        self.frame_state.async_tasks.set_runtime(handle);
450    }
451
452    /// Get the rendered text content of row y (trimmed trailing spaces)
453    pub fn line(&self, y: u32) -> String {
454        let mut s = String::new();
455        for x in 0..self.width {
456            s.push_str(&self.buffer.get(x, y).symbol);
457        }
458        s.trim_end().to_string()
459    }
460
461    /// Assert that row y contains `expected` as a substring
462    pub fn assert_line(&self, y: u32, expected: &str) {
463        let line = self.line(y);
464        assert_eq!(
465            line, expected,
466            "Line {y}: expected {expected:?}, got {line:?}"
467        );
468    }
469
470    /// Assert that row y contains `expected` as a substring
471    pub fn assert_line_contains(&self, y: u32, expected: &str) {
472        let line = self.line(y);
473        assert!(
474            line.contains(expected),
475            "Line {y}: expected to contain {expected:?}, got {line:?}"
476        );
477    }
478
479    /// Assert that any line in the buffer contains `expected`
480    pub fn assert_contains(&self, expected: &str) {
481        for y in 0..self.height {
482            if self.line(y).contains(expected) {
483                return;
484            }
485        }
486        let mut all_lines = String::new();
487        for y in 0..self.height {
488            all_lines.push_str(&format!("{}: {}\n", y, self.line(y)));
489        }
490        panic!("Buffer does not contain {expected:?}.\nBuffer:\n{all_lines}");
491    }
492
493    /// Access the underlying render buffer.
494    pub fn buffer(&self) -> &Buffer {
495        &self.buffer
496    }
497
498    /// Terminal width used for this backend.
499    pub fn width(&self) -> u32 {
500        self.width
501    }
502
503    /// Terminal height used for this backend.
504    pub fn height(&self) -> u32 {
505        self.height
506    }
507
508    /// Return the full rendered buffer as a multi-line string.
509    ///
510    /// Each row is trimmed of trailing spaces and joined with newlines.
511    /// Useful for snapshot testing with `insta::assert_snapshot!`.
512    pub fn to_string_trimmed(&self) -> String {
513        let mut lines = Vec::with_capacity(self.height as usize);
514        for y in 0..self.height {
515            lines.push(self.line(y));
516        }
517        while lines.last().is_some_and(|l| l.is_empty()) {
518            lines.pop();
519        }
520        lines.join("\n")
521    }
522
523    // ---- Negative assertions (#232) ---------------------------------------
524
525    /// Assert that no row in the buffer contains `expected` as a substring.
526    ///
527    /// Panics with the offending row indices and contents on failure.
528    pub fn assert_not_contains(&self, expected: &str) {
529        let mut offending: Vec<(u32, String)> = Vec::new();
530        for y in 0..self.height {
531            let line = self.line(y);
532            if line.contains(expected) {
533                offending.push((y, line));
534            }
535        }
536        if !offending.is_empty() {
537            let detail = offending
538                .iter()
539                .map(|(y, l)| format!("  row {y}: {l:?}"))
540                .collect::<Vec<_>>()
541                .join("\n");
542            panic!("Buffer unexpectedly contains {expected:?}:\n{detail}");
543        }
544    }
545
546    /// Assert that row `y` does NOT contain `expected` as a substring.
547    pub fn assert_line_not_contains(&self, y: u32, expected: &str) {
548        let line = self.line(y);
549        assert!(
550            !line.contains(expected),
551            "Line {y}: expected NOT to contain {expected:?}, but got {line:?}"
552        );
553    }
554
555    /// Assert that row `y` is entirely blank (contains no non-space content).
556    ///
557    /// Useful for verifying that cleared, padded, or overflow-suppressed rows
558    /// render as empty.
559    pub fn assert_empty_line(&self, y: u32) {
560        let line = self.line(y);
561        assert!(line.is_empty(), "Line {y}: expected empty, got {line:?}");
562    }
563
564    /// Assert that the cell at `(x, y)` carries exactly the `expected` style.
565    ///
566    /// Useful for focused color/modifier regression checks without committing
567    /// to a full-buffer snapshot. Panics with `(x, y)`, the actual style, and
568    /// the expected style on mismatch.
569    pub fn assert_style_at(&self, x: u32, y: u32, expected: Style) {
570        let actual = self.buffer.get(x, y).style;
571        assert_eq!(
572            actual, expected,
573            "Style mismatch at ({x}, {y}): expected {expected:?}, got {actual:?}"
574        );
575    }
576
577    // ---- Region queries + snapshot diffing (#283) -------------------------
578
579    /// Find the first buffer position where `needle` begins in the rendered
580    /// text grid, scanning rows top-to-bottom and columns left-to-right.
581    ///
582    /// Each cell contributes its glyph at the cell's own column; empty cells
583    /// (blanks and wide-char trailing cells) count as a single space so the
584    /// returned `x` is the actual buffer column where the match starts. The
585    /// search is per-row — a needle that wraps across a row boundary is not
586    /// matched. Returns `None` if `needle` is empty or absent.
587    ///
588    /// # Example
589    ///
590    /// ```
591    /// use slt::TestBackend;
592    ///
593    /// let mut tb = TestBackend::new(20, 2);
594    /// tb.render(|ui| {
595    ///     ui.text("  hello");
596    /// });
597    /// assert_eq!(tb.find_text("hello"), Some((2, 0)));
598    /// assert_eq!(tb.find_text("nope"), None);
599    /// ```
600    pub fn find_text(&self, needle: &str) -> Option<(u32, u32)> {
601        if needle.is_empty() {
602            return None;
603        }
604        for y in 0..self.height {
605            // Build the row text alongside a per-character map back to the
606            // originating buffer column, so a byte match in `row` resolves to
607            // the correct `x`. Empty cells render as a single space.
608            let mut row = String::new();
609            // byte offset in `row` -> buffer column x
610            let mut col_at_byte: Vec<u32> = Vec::with_capacity(self.width as usize);
611            for x in 0..self.width {
612                let cell = self.buffer.get(x, y);
613                let sym: &str = if cell.symbol.is_empty() {
614                    " "
615                } else {
616                    cell.symbol.as_str()
617                };
618                for _ in 0..sym.len() {
619                    col_at_byte.push(x);
620                }
621                row.push_str(sym);
622            }
623            if let Some(byte_idx) = row.find(needle) {
624                let x = col_at_byte.get(byte_idx).copied().unwrap_or(0);
625                return Some((x, y));
626            }
627        }
628        None
629    }
630
631    /// Assert that the rectangular region anchored at `(x, y)` with width `w`
632    /// and height `h` renders exactly `expected` (rows joined with `\n`).
633    ///
634    /// Each region row is the slice of buffer columns `x..x+w` on buffer row
635    /// `y..y+h`, with empty cells rendered as a space and **trailing** spaces
636    /// of each region row preserved (so width is significant). Columns or rows
637    /// that fall outside the buffer are treated as blanks. Panics with an
638    /// aligned expected-vs-actual diff on mismatch.
639    ///
640    /// # Example
641    ///
642    /// ```
643    /// use slt::TestBackend;
644    ///
645    /// let mut tb = TestBackend::new(10, 3);
646    /// tb.render(|ui| {
647    ///     let _ = ui.col(|ui| {
648    ///         ui.text("ab");
649    ///         ui.text("cd");
650    ///     });
651    /// });
652    /// tb.assert_region(0, 0, 2, 2, "ab\ncd");
653    /// ```
654    pub fn assert_region(&self, x: u32, y: u32, w: u32, h: u32, expected: &str) {
655        let actual = self.region(x, y, w, h);
656        if actual != expected {
657            panic!(
658                "Region ({x}, {y}, {w}x{h}) mismatch.\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n----------------"
659            );
660        }
661    }
662
663    /// Render the rectangular region anchored at `(x, y)` with width `w` and
664    /// height `h` as a multi-line string (rows joined with `\n`).
665    ///
666    /// Empty cells render as a single space and trailing spaces are preserved,
667    /// so the result is exactly `w` columns wide per row. Columns or rows
668    /// outside the buffer are blank-filled. Useful for scoping a snapshot to a
669    /// sub-rectangle without asserting on the full buffer.
670    pub fn region(&self, x: u32, y: u32, w: u32, h: u32) -> String {
671        let mut rows = Vec::with_capacity(h as usize);
672        for row in y..y.saturating_add(h) {
673            let mut s = String::new();
674            for col in x..x.saturating_add(w) {
675                if row < self.height && col < self.width {
676                    let cell = self.buffer.get(col, row);
677                    if cell.symbol.is_empty() {
678                        s.push(' ');
679                    } else {
680                        s.push_str(cell.symbol.as_str());
681                    }
682                } else {
683                    s.push(' ');
684                }
685            }
686            rows.push(s);
687        }
688        rows.join("\n")
689    }
690
691    /// Assert that `needle` is rendered somewhere in the buffer AND every cell
692    /// of the matched run satisfies `predicate` (applied to each cell's
693    /// [`Style`]).
694    ///
695    /// Combines a content check with a per-cell style check, which is more
696    /// ergonomic than pairing [`find_text`](TestBackend::find_text) with
697    /// repeated [`assert_style_at`](TestBackend::assert_style_at) calls. The
698    /// run is located with `find_text` (per-row, left-to-right), then each of
699    /// the `needle`'s `char`-count cells starting at the match is tested.
700    /// Panics if the needle is absent or any covered cell fails the predicate.
701    ///
702    /// # Example
703    ///
704    /// ```
705    /// use slt::{Color, TestBackend};
706    ///
707    /// let mut tb = TestBackend::new(20, 1);
708    /// tb.render(|ui| {
709    ///     ui.text("hi").fg(Color::Red).bold();
710    /// });
711    /// tb.assert_styled_contains("hi", |s| {
712    ///     s.fg == Some(Color::Red) && s.modifiers.contains(slt::Modifiers::BOLD)
713    /// });
714    /// ```
715    pub fn assert_styled_contains(&self, needle: &str, predicate: impl Fn(&Style) -> bool) {
716        let Some((x, y)) = self.find_text(needle) else {
717            let mut all_lines = String::new();
718            for row in 0..self.height {
719                all_lines.push_str(&format!("{}: {}\n", row, self.line(row)));
720            }
721            panic!("Buffer does not contain {needle:?}.\nBuffer:\n{all_lines}");
722        };
723        // The match spans one cell per `char` in the needle. Wide glyphs occupy
724        // their own cell; the trailing blank cell is not part of the run.
725        let span = needle.chars().count() as u32;
726        for offset in 0..span {
727            let cx = x + offset;
728            let style = self.buffer.get(cx, y).style;
729            assert!(
730                predicate(&style),
731                "Style predicate failed for {needle:?} at cell ({cx}, {y}): style is {style:?}"
732            );
733        }
734    }
735
736    /// Produce a stable, plain-text snapshot of the whole buffer.
737    ///
738    /// Every buffer row is rendered exactly `width` columns wide (empty cells
739    /// as spaces, no trailing trim) and joined with `\n`. Unlike
740    /// [`to_string_trimmed`](TestBackend::to_string_trimmed), no trailing blank
741    /// rows are dropped and per-row width is fixed, giving a deterministic
742    /// snapshot suitable for [`assert_snapshot_eq`](TestBackend::assert_snapshot_eq)
743    /// or external snapshot tooling.
744    ///
745    /// # Example
746    ///
747    /// ```
748    /// use slt::TestBackend;
749    ///
750    /// let mut tb = TestBackend::new(3, 2);
751    /// tb.render(|ui| {
752    ///     ui.text("ab");
753    /// });
754    /// assert_eq!(tb.snapshot(), "ab \n   ");
755    /// ```
756    pub fn snapshot(&self) -> String {
757        self.region(0, 0, self.width, self.height)
758    }
759
760    /// Assert the buffer [`snapshot`](TestBackend::snapshot) equals `expected`,
761    /// panicking with a unified-diff-style report on mismatch.
762    ///
763    /// Trailing whitespace on each line of `expected` is ignored (the actual
764    /// snapshot is right-padded to the buffer width), so callers can write
765    /// trimmed expected strings. The panic message lists each differing row
766    /// with `-` (expected) / `+` (actual) markers.
767    ///
768    /// # Example
769    ///
770    /// ```
771    /// use slt::TestBackend;
772    ///
773    /// let mut tb = TestBackend::new(5, 2);
774    /// tb.render(|ui| {
775    ///     ui.text("hi");
776    /// });
777    /// tb.assert_snapshot_eq("hi\n");
778    /// ```
779    pub fn assert_snapshot_eq(&self, expected: &str) {
780        let actual = self.snapshot();
781        // Compare row-by-row, ignoring trailing whitespace differences so the
782        // expected literal can be written without padding to the full width.
783        let actual_rows: Vec<&str> = actual.lines().collect();
784        let expected_rows: Vec<&str> = expected.lines().collect();
785        let row_count = actual_rows.len().max(expected_rows.len());
786        let mut mismatched = false;
787        for i in 0..row_count {
788            let a = actual_rows.get(i).copied().unwrap_or("");
789            let e = expected_rows.get(i).copied().unwrap_or("");
790            if a.trim_end() != e.trim_end() {
791                mismatched = true;
792                break;
793            }
794        }
795        if mismatched {
796            let mut diff = String::new();
797            for i in 0..row_count {
798                let a = actual_rows.get(i).copied().unwrap_or("");
799                let e = expected_rows.get(i).copied().unwrap_or("");
800                if a.trim_end() == e.trim_end() {
801                    diff.push_str(&format!("  {}\n", a.trim_end()));
802                } else {
803                    diff.push_str(&format!("- {}\n", e.trim_end()));
804                    diff.push_str(&format!("+ {}\n", a.trim_end()));
805                }
806            }
807            panic!("Snapshot mismatch (- expected, + actual):\n{diff}");
808        }
809    }
810
811    // ---- Multi-step sequences + type_string (#230) ------------------------
812
813    /// Begin building a multi-step interaction sequence.
814    ///
815    /// Each [`tick`](TestSequence::tick) (or [`key`](TestSequence::key))
816    /// appends an event batch + render closure pair.
817    /// [`run`](TestSequence::run) executes them in order, advancing
818    /// `FrameState` naturally between steps so callers don't need to thread
819    /// `focus_index` / `prev_focus_count` manually.
820    ///
821    /// # Example
822    ///
823    /// ```
824    /// use slt::{KeyCode, TestBackend};
825    ///
826    /// let mut tb = TestBackend::new(20, 3);
827    /// tb.sequence()
828    ///     .tick(|ui| { ui.text("ready"); })
829    ///     .key(KeyCode::Esc, |ui| { ui.text("after esc"); })
830    ///     .run();
831    /// tb.assert_contains("after esc");
832    /// ```
833    pub fn sequence(&mut self) -> TestSequence<'_> {
834        TestSequence {
835            backend: self,
836            steps: Vec::new(),
837        }
838    }
839
840    /// Simulate typing `s` one character at a time, rendering with `render`
841    /// between each character.
842    ///
843    /// Each character produces a [`KeyCode::Char`] event with no modifiers.
844    /// Focus state is preserved across characters.
845    ///
846    /// # Example
847    ///
848    /// ```
849    /// use slt::TestBackend;
850    ///
851    /// let mut tb = TestBackend::new(20, 3);
852    /// let mut typed = String::new();
853    /// tb.type_string("hi", |ui| {
854    ///     ui.text(&typed);
855    /// });
856    /// // 2 characters → 2 frames rendered.
857    /// drop(typed);
858    /// ```
859    pub fn type_string(&mut self, s: &str, mut render: impl FnMut(&mut Context)) {
860        for ch in s.chars() {
861            let events = vec![Event::Key(KeyEvent {
862                code: KeyCode::Char(ch),
863                modifiers: KeyModifiers::NONE,
864                kind: KeyEventKind::Press,
865            })];
866            // Use render_frame directly so frame recording is preserved and
867            // FrameState advances naturally between characters.
868            self.render_frame(events, |_| {}, &mut render);
869        }
870    }
871}
872
873/// A single step in a [`TestSequence`].
874///
875/// Holds the event batch to inject, plus a render closure to execute. Created
876/// internally by [`TestSequence::tick`], [`TestSequence::key`],
877/// [`TestSequence::events`], etc.
878struct TestStep<'a> {
879    events: Vec<Event>,
880    render: Box<dyn FnOnce(&mut Context) + 'a>,
881}
882
883/// Builder returned by [`TestBackend::sequence`].
884///
885/// Chain step builders (`tick`, `key`, `type_string`, `events`) and finalize
886/// with [`run`](TestSequence::run). Steps execute sequentially, advancing
887/// `FrameState` between them so focus and hooks evolve naturally without the
888/// caller having to thread state.
889pub struct TestSequence<'a> {
890    backend: &'a mut TestBackend,
891    steps: Vec<TestStep<'a>>,
892}
893
894impl<'a> TestSequence<'a> {
895    /// Append a step that renders without injecting any events.
896    ///
897    /// Equivalent to a single frame tick — useful for letting hooks /
898    /// animations advance between input steps.
899    pub fn tick(mut self, f: impl FnOnce(&mut Context) + 'a) -> Self {
900        self.steps.push(TestStep {
901            events: Vec::new(),
902            render: Box::new(f),
903        });
904        self
905    }
906
907    /// Append a step that fires a single key-press event with no modifiers.
908    pub fn key(mut self, code: KeyCode, f: impl FnOnce(&mut Context) + 'a) -> Self {
909        let events = vec![Event::Key(KeyEvent {
910            code,
911            modifiers: KeyModifiers::NONE,
912            kind: KeyEventKind::Press,
913        })];
914        self.steps.push(TestStep {
915            events,
916            render: Box::new(f),
917        });
918        self
919    }
920
921    /// Append a step that types `s` as a sequence of `KeyCode::Char` events
922    /// **before** invoking `render`.
923    ///
924    /// Unlike [`TestBackend::type_string`], this collapses every typed
925    /// character into a single render step — useful when the per-character
926    /// frame state is not the assertion target. For per-keystroke rendering,
927    /// chain individual `.key(...)` calls.
928    pub fn type_string(mut self, s: &str, f: impl FnOnce(&mut Context) + 'a) -> Self {
929        let events = s
930            .chars()
931            .map(|c| {
932                Event::Key(KeyEvent {
933                    code: KeyCode::Char(c),
934                    modifiers: KeyModifiers::NONE,
935                    kind: KeyEventKind::Press,
936                })
937            })
938            .collect();
939        self.steps.push(TestStep {
940            events,
941            render: Box::new(f),
942        });
943        self
944    }
945
946    /// Append a step with an arbitrary event batch.
947    ///
948    /// Useful for mouse interactions, paste events, or sequences built
949    /// with [`EventBuilder`].
950    pub fn events(mut self, events: Vec<Event>, f: impl FnOnce(&mut Context) + 'a) -> Self {
951        self.steps.push(TestStep {
952            events,
953            render: Box::new(f),
954        });
955        self
956    }
957
958    /// Execute every queued step in order. Returns control to the caller
959    /// (the [`TestBackend`] is borrowed mutably for the lifetime of the
960    /// sequence builder). Use [`TestBackend::buffer`] / `.frames()` /
961    /// `.assert_*` after `run()` returns.
962    pub fn run(self) {
963        let backend = self.backend;
964        for step in self.steps {
965            let TestStep { events, render } = step;
966            // Adapt FnOnce(&mut Context) into the &mut FnMut(&mut Context)
967            // shape that render_frame's internal trampoline already expects.
968            let mut once = Some(render);
969            let f = move |ui: &mut Context| {
970                if let Some(f) = once.take() {
971                    f(ui);
972                }
973            };
974            backend.render_frame(events, |_| {}, f);
975        }
976    }
977}
978
979impl std::fmt::Display for TestBackend {
980    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981        write!(f, "{}", self.to_string_trimmed())
982    }
983}
984
985// ---------------------------------------------------------------------------
986// PtyBackend — end-to-end escape-byte / image-protocol capture (#274)
987// ---------------------------------------------------------------------------
988
989/// Raw bytes emitted for a single rendered frame by [`PtyBackend`].
990///
991/// Unlike [`FrameRecord`] (a glyph/snapshot view of the in-memory
992/// [`Buffer`]), `PtyFrame` holds the *actual* escape-code byte stream the
993/// production flush pipeline produced for the frame: SGR runs, OSC 8
994/// hyperlinks, Sixel (`\x1bPq`), and Kitty graphics (`\x1b_Ga=`).
995///
996/// Since 0.21.0.
997#[cfg(feature = "pty-test")]
998#[derive(Clone, Debug)]
999pub struct PtyFrame {
1000    /// Raw bytes emitted for this frame (SGR runs, OSC 8, Sixel, Kitty).
1001    pub raw: Vec<u8>,
1002}
1003
1004/// Drives the *real* [`crate::run`] flush pipeline into an in-process byte
1005/// sink, so escape-code / color-depth / image-protocol output is asserted
1006/// end-to-end — the byte/protocol tier that [`TestBackend`]'s buffer-only
1007/// model deliberately cannot reach (see `tests/visual_snapshots.rs`).
1008///
1009/// Each [`render`](PtyBackend::render) constructs a fresh fullscreen
1010/// `Terminal` whose sink is a captured `Vec<u8>` (no real TTY, no raw mode),
1011/// runs one frame through the same [`crate::frame_owned`] entry point the
1012/// production loop uses, and captures the emitted bytes. Because the previous
1013/// frame buffer starts empty, every frame emits a complete first-paint diff —
1014/// fully deterministic and reproducible on a headless CI runner.
1015///
1016/// This type is gated behind the dev-only `pty-test` feature and is **not**
1017/// present in a default build.
1018///
1019/// Since 0.21.0.
1020///
1021/// # Example
1022///
1023/// ```no_run
1024/// # #[cfg(feature = "pty-test")]
1025/// # {
1026/// use slt::{Color, PtyBackend};
1027///
1028/// let mut pb = PtyBackend::new(10, 1);
1029/// pb.render(|ui| {
1030///     ui.text("x").fg(Color::Red).bold();
1031/// });
1032/// // The real flush pipeline emitted an SGR sequence for the styled glyph.
1033/// pb.assert_emits("\u{1b}[");
1034/// # }
1035/// ```
1036#[cfg(feature = "pty-test")]
1037pub struct PtyBackend {
1038    width: u32,
1039    height: u32,
1040    color_depth: crate::style::ColorDepth,
1041    state: crate::AppState,
1042    config: RunConfig,
1043    frames: Vec<PtyFrame>,
1044}
1045
1046#[cfg(feature = "pty-test")]
1047impl PtyBackend {
1048    /// Create a PTY capture backend with the given terminal dimensions.
1049    ///
1050    /// Defaults to [`ColorDepth::TrueColor`](crate::ColorDepth::TrueColor);
1051    /// override with [`with_color_depth`](PtyBackend::with_color_depth).
1052    pub fn new(width: u32, height: u32) -> Self {
1053        Self {
1054            width,
1055            height,
1056            color_depth: crate::style::ColorDepth::TrueColor,
1057            state: crate::AppState::new(),
1058            config: RunConfig::default(),
1059            frames: Vec::new(),
1060        }
1061    }
1062
1063    /// Set the [`ColorDepth`](crate::ColorDepth) the flush pipeline encodes
1064    /// SGR colors with (e.g. truecolor vs 256-color). Returns `self` for
1065    /// chaining.
1066    pub fn with_color_depth(mut self, depth: crate::style::ColorDepth) -> Self {
1067        self.color_depth = depth;
1068        self
1069    }
1070
1071    /// Render one frame through the real `Terminal` flush pipeline, capturing
1072    /// the emitted bytes. Returns the just-captured [`PtyFrame`].
1073    pub fn render(&mut self, f: impl FnOnce(&mut Context)) -> &PtyFrame {
1074        self.render_with_events(Vec::new(), f)
1075    }
1076
1077    /// Render one frame with injected input `events`, capturing the emitted
1078    /// bytes. Returns the just-captured [`PtyFrame`].
1079    pub fn render_with_events(
1080        &mut self,
1081        events: Vec<Event>,
1082        f: impl FnOnce(&mut Context),
1083    ) -> &PtyFrame {
1084        let mut term =
1085            crate::terminal::Terminal::with_sink(self.width, self.height, self.color_depth);
1086        let mut once = Some(f);
1087        let mut render = move |ui: &mut Context| {
1088            if let Some(f) = once.take() {
1089                f(ui);
1090            }
1091        };
1092        // Drive the production single-frame entry point. The captured-sink
1093        // Terminal routes every byte through flush_buffer_diff /
1094        // apply_style_delta / Sixel / Kitty exactly as a real terminal would.
1095        let _ = crate::frame_owned(
1096            &mut term,
1097            &mut self.state,
1098            &self.config,
1099            events,
1100            &mut render,
1101        );
1102        let raw = term.take_sink_bytes();
1103        self.frames.push(PtyFrame { raw });
1104        self.frames.last().expect("frame just pushed")
1105    }
1106
1107    /// Iterate the raw byte stream of every captured frame, oldest first.
1108    pub fn frames_raw(&self) -> impl Iterator<Item = &[u8]> {
1109        self.frames.iter().map(|f| f.raw.as_slice())
1110    }
1111
1112    /// Raw bytes of the most recently rendered frame.
1113    ///
1114    /// Panics if no frame has been rendered yet.
1115    pub fn last_raw(&self) -> &[u8] {
1116        &self.frames.last().expect("no frame rendered").raw
1117    }
1118
1119    /// Assert the last frame's byte stream contains `needle`.
1120    ///
1121    /// Panics with an escaped + hex dump of the emitted bytes on a miss.
1122    pub fn assert_emits(&self, needle: &str) {
1123        let raw = self.last_raw();
1124        if find_subslice(raw, needle.as_bytes()).is_none() {
1125            panic!(
1126                "PtyBackend frame does not emit {:?}.\nEmitted ({} bytes):\n  escaped: {}\n  hex: {}",
1127                needle,
1128                raw.len(),
1129                escape_bytes(raw),
1130                hex_bytes(raw),
1131            );
1132        }
1133    }
1134
1135    /// Assert the last frame's byte stream does **not** contain `needle`.
1136    ///
1137    /// Panics with an escaped + hex dump on an unexpected hit.
1138    pub fn assert_not_emits(&self, needle: &str) {
1139        let raw = self.last_raw();
1140        if find_subslice(raw, needle.as_bytes()).is_some() {
1141            panic!(
1142                "PtyBackend frame unexpectedly emits {:?}.\nEmitted ({} bytes):\n  escaped: {}\n  hex: {}",
1143                needle,
1144                raw.len(),
1145                escape_bytes(raw),
1146                hex_bytes(raw),
1147            );
1148        }
1149    }
1150}
1151
1152/// Byte-substring search (no UTF-8 assumption — escape streams are not valid
1153/// UTF-8 in general).
1154#[cfg(feature = "pty-test")]
1155fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1156    if needle.is_empty() {
1157        return Some(0);
1158    }
1159    haystack.windows(needle.len()).position(|w| w == needle)
1160}
1161
1162/// Render a byte slice with non-printable bytes shown as `\xNN` escapes.
1163#[cfg(feature = "pty-test")]
1164fn escape_bytes(bytes: &[u8]) -> String {
1165    let mut s = String::with_capacity(bytes.len());
1166    for &b in bytes {
1167        match b {
1168            0x1b => s.push_str("\\x1b"),
1169            0x20..=0x7e => s.push(b as char),
1170            b'\n' => s.push_str("\\n"),
1171            b'\r' => s.push_str("\\r"),
1172            b'\t' => s.push_str("\\t"),
1173            _ => s.push_str(&format!("\\x{b:02x}")),
1174        }
1175    }
1176    s
1177}
1178
1179/// Render a byte slice as space-separated two-digit hex.
1180#[cfg(feature = "pty-test")]
1181fn hex_bytes(bytes: &[u8]) -> String {
1182    bytes
1183        .iter()
1184        .map(|b| format!("{b:02x}"))
1185        .collect::<Vec<_>>()
1186        .join(" ")
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192    use crate::event::{KeyEventKind, MouseKind};
1193
1194    /// Regression test for issue #131: `mouse_up` produces `MouseKind::Up(Left)`.
1195    #[test]
1196    fn event_builder_mouse_up_produces_up_event() {
1197        let events = EventBuilder::new().mouse_up(5, 3).build();
1198        assert_eq!(events.len(), 1);
1199        match &events[0] {
1200            Event::Mouse(m) => {
1201                assert!(matches!(m.kind, MouseKind::Up(MouseButton::Left)));
1202                assert_eq!(m.x, 5);
1203                assert_eq!(m.y, 3);
1204            }
1205            _ => panic!("expected mouse event"),
1206        }
1207    }
1208
1209    /// Regression test for issue #131: `drag` produces a drag mouse event.
1210    #[test]
1211    fn event_builder_drag_produces_drag_event() {
1212        let events = EventBuilder::new().drag(10, 5).build();
1213        assert_eq!(events.len(), 1);
1214        match &events[0] {
1215            Event::Mouse(m) => {
1216                assert!(matches!(m.kind, MouseKind::Drag(MouseButton::Left)));
1217                assert_eq!(m.x, 10);
1218                assert_eq!(m.y, 5);
1219            }
1220            _ => panic!("expected mouse event"),
1221        }
1222    }
1223
1224    /// Regression test for issue #131: `key_release` produces a release key event.
1225    #[test]
1226    fn event_builder_key_release_produces_release_event() {
1227        let events = EventBuilder::new().key_release('a').build();
1228        assert_eq!(events.len(), 1);
1229        match &events[0] {
1230            Event::Key(k) => {
1231                assert_eq!(k.code, KeyCode::Char('a'));
1232                assert!(matches!(k.kind, KeyEventKind::Release));
1233            }
1234            _ => panic!("expected key event"),
1235        }
1236    }
1237
1238    /// Regression test for issue #131: focus_gained / focus_lost chain through builder.
1239    #[test]
1240    fn event_builder_focus_events_chaining() {
1241        let events = EventBuilder::new().focus_lost().focus_gained().build();
1242        assert_eq!(events, vec![Event::FocusLost, Event::FocusGained]);
1243    }
1244
1245    /// Issue #261: `key_modifier` builds a single modifier-only key-press event.
1246    #[test]
1247    fn event_builder_key_modifier_produces_modifier_event() {
1248        let events = EventBuilder::new()
1249            .key_modifier(ModifierKey::LeftSuper)
1250            .build();
1251        assert_eq!(events.len(), 1);
1252        match &events[0] {
1253            Event::Key(k) => {
1254                assert_eq!(k.code, KeyCode::Modifier(ModifierKey::LeftSuper));
1255                assert_eq!(k.modifiers, KeyModifiers::NONE);
1256                assert!(matches!(k.kind, KeyEventKind::Press));
1257            }
1258            _ => panic!("expected key event"),
1259        }
1260    }
1261
1262    /// Issue #261: a modifier-only event reaches the frame closure end-to-end.
1263    #[test]
1264    fn modifier_key_event_reaches_frame_closure() {
1265        let mut tb = TestBackend::new(20, 2);
1266        let events = EventBuilder::new()
1267            .key_modifier(ModifierKey::LeftCtrl)
1268            .build();
1269        tb.sequence()
1270            .events(events, |ui| {
1271                if ui.key_code(KeyCode::Modifier(ModifierKey::LeftCtrl)) {
1272                    ui.text("ctrl-down");
1273                } else {
1274                    ui.text("idle");
1275                }
1276            })
1277            .run();
1278        tb.assert_contains("ctrl-down");
1279    }
1280
1281    // ---- #229 record_frames -------------------------------------------------
1282
1283    #[test]
1284    fn record_frames_disabled_returns_empty_slice() {
1285        let mut tb = TestBackend::new(10, 2);
1286        tb.render(|ui| {
1287            ui.text("hi");
1288        });
1289        assert!(tb.frames().is_empty());
1290    }
1291
1292    #[test]
1293    fn record_frames_captures_each_render() {
1294        let mut tb = TestBackend::new(20, 2).record_frames();
1295        for n in 0..3 {
1296            tb.render(|ui| {
1297                ui.text(format!("frame {n}"));
1298            });
1299        }
1300        assert_eq!(tb.frames().len(), 3);
1301        tb.frames()[0].assert_contains("frame 0");
1302        tb.frames()[1].assert_contains("frame 1");
1303        tb.frames()[2].assert_contains("frame 2");
1304    }
1305
1306    #[test]
1307    fn record_frames_stores_styled_snapshot() {
1308        let mut tb = TestBackend::new(10, 1).record_frames();
1309        tb.render(|ui| {
1310            ui.text("hi").bold();
1311        });
1312        let frame = &tb.frames()[0];
1313        // Styled snapshot should encode the bold modifier somewhere.
1314        assert!(
1315            frame.snapshot.contains("bold"),
1316            "snapshot missing bold marker: {:?}",
1317            frame.snapshot
1318        );
1319    }
1320
1321    #[test]
1322    fn record_frames_idempotent_when_called_twice() {
1323        // record_frames() called twice must not wipe prior history.
1324        let tb = TestBackend::new(10, 1).record_frames();
1325        let mut tb = tb.record_frames();
1326        tb.render(|ui| {
1327            ui.text("a");
1328        });
1329        assert_eq!(tb.frames().len(), 1);
1330    }
1331
1332    #[test]
1333    fn frame_record_to_string_trimmed_drops_trailing_blank_rows() {
1334        let mut tb = TestBackend::new(10, 4).record_frames();
1335        tb.render(|ui| {
1336            ui.text("hello");
1337        });
1338        let frame = &tb.frames()[0];
1339        // The frame should have all 4 rows recorded.
1340        assert_eq!(frame.lines.len(), 4);
1341        // to_string_trimmed drops the trailing empty rows like TestBackend.
1342        let s = frame.to_string_trimmed();
1343        assert!(!s.ends_with('\n'));
1344        assert!(s.starts_with("hello"));
1345    }
1346
1347    // ---- #230 sequence + type_string ----------------------------------------
1348
1349    #[test]
1350    fn sequence_runs_multiple_steps_in_order() {
1351        let mut tb = TestBackend::new(20, 2).record_frames();
1352        tb.sequence()
1353            .tick(|ui| {
1354                ui.text("step-1");
1355            })
1356            .tick(|ui| {
1357                ui.text("step-2");
1358            })
1359            .tick(|ui| {
1360                ui.text("step-3");
1361            })
1362            .run();
1363        assert_eq!(tb.frames().len(), 3);
1364        tb.frames()[0].assert_contains("step-1");
1365        tb.frames()[1].assert_contains("step-2");
1366        tb.frames()[2].assert_contains("step-3");
1367    }
1368
1369    #[test]
1370    fn sequence_key_step_injects_event() {
1371        // We can't easily observe the key event without a stateful widget,
1372        // but we can confirm the sequence builder ran the render closure.
1373        let mut tb = TestBackend::new(20, 2);
1374        tb.sequence()
1375            .key(KeyCode::Esc, |ui| {
1376                ui.text("after-esc");
1377            })
1378            .run();
1379        tb.assert_contains("after-esc");
1380    }
1381
1382    #[test]
1383    fn sequence_type_string_collapses_into_single_step() {
1384        let mut tb = TestBackend::new(20, 2).record_frames();
1385        tb.sequence()
1386            .type_string("abc", |ui| {
1387                ui.text("done");
1388            })
1389            .run();
1390        // Sequence's type_string is one step → one frame, not three.
1391        assert_eq!(tb.frames().len(), 1);
1392        tb.frames()[0].assert_contains("done");
1393    }
1394
1395    #[test]
1396    fn sequence_events_step_takes_arbitrary_batch() {
1397        let mut tb = TestBackend::new(20, 2);
1398        let events = EventBuilder::new()
1399            .key('a')
1400            .key_code(KeyCode::Enter)
1401            .build();
1402        tb.sequence()
1403            .events(events, |ui| {
1404                ui.text("ran");
1405            })
1406            .run();
1407        tb.assert_contains("ran");
1408    }
1409
1410    #[test]
1411    fn type_string_renders_one_frame_per_char() {
1412        let mut tb = TestBackend::new(20, 2).record_frames();
1413        tb.type_string("abc", |ui| {
1414            ui.text("char");
1415        });
1416        assert_eq!(tb.frames().len(), 3);
1417    }
1418
1419    #[test]
1420    fn type_string_handles_empty_input() {
1421        let mut tb = TestBackend::new(20, 2).record_frames();
1422        tb.type_string("", |ui| {
1423            ui.text("never-called");
1424        });
1425        assert_eq!(tb.frames().len(), 0);
1426    }
1427
1428    // ---- #232 negative assertions ------------------------------------------
1429
1430    #[test]
1431    fn assert_not_contains_passes_when_absent() {
1432        let mut tb = TestBackend::new(20, 2);
1433        tb.render(|ui| {
1434            ui.text("hello world");
1435        });
1436        tb.assert_not_contains("error");
1437    }
1438
1439    #[test]
1440    #[should_panic(expected = "Buffer unexpectedly contains")]
1441    fn assert_not_contains_panics_when_present() {
1442        let mut tb = TestBackend::new(20, 2);
1443        tb.render(|ui| {
1444            ui.text("error: fail");
1445        });
1446        tb.assert_not_contains("error");
1447    }
1448
1449    #[test]
1450    fn assert_line_not_contains_passes_when_other_row_has_substring() {
1451        let mut tb = TestBackend::new(20, 3);
1452        tb.render(|ui| {
1453            let _ = ui.col(|ui| {
1454                ui.text("first");
1455                ui.text("second");
1456            });
1457        });
1458        // Line 0 has "first" but not "second".
1459        tb.assert_line_not_contains(0, "second");
1460    }
1461
1462    #[test]
1463    #[should_panic(expected = "Line 0: expected NOT to contain")]
1464    fn assert_line_not_contains_panics_when_present() {
1465        let mut tb = TestBackend::new(20, 1);
1466        tb.render(|ui| {
1467            ui.text("hello");
1468        });
1469        tb.assert_line_not_contains(0, "ello");
1470    }
1471
1472    #[test]
1473    fn assert_empty_line_passes_for_blank_row() {
1474        let mut tb = TestBackend::new(20, 2);
1475        tb.render(|ui| {
1476            ui.text("only-row-0");
1477        });
1478        // Row 1 is untouched after rendering one text → blank.
1479        tb.assert_empty_line(1);
1480    }
1481
1482    #[test]
1483    #[should_panic(expected = "Line 0: expected empty")]
1484    fn assert_empty_line_panics_when_non_blank() {
1485        let mut tb = TestBackend::new(20, 2);
1486        tb.render(|ui| {
1487            ui.text("not-empty");
1488        });
1489        tb.assert_empty_line(0);
1490    }
1491
1492    #[test]
1493    fn assert_style_at_passes_for_matching_style() {
1494        use crate::style::{Color, Modifiers};
1495        let mut tb = TestBackend::new(10, 1);
1496        tb.render(|ui| {
1497            ui.text("x").fg(Color::Red);
1498        });
1499        let expected = Style {
1500            fg: Some(Color::Red),
1501            bg: None,
1502            modifiers: Modifiers::NONE,
1503            ..Style::new()
1504        };
1505        tb.assert_style_at(0, 0, expected);
1506    }
1507
1508    #[test]
1509    #[should_panic(expected = "Style mismatch")]
1510    fn assert_style_at_panics_on_mismatch() {
1511        use crate::style::Color;
1512        let mut tb = TestBackend::new(10, 1);
1513        tb.render(|ui| {
1514            ui.text("x").fg(Color::Red);
1515        });
1516        let expected = Style::new().fg(Color::Blue);
1517        tb.assert_style_at(0, 0, expected);
1518    }
1519
1520    // ---- #283 region queries + snapshot diffing -----------------------------
1521
1522    #[test]
1523    fn find_text_returns_first_match_position() {
1524        let mut tb = TestBackend::new(20, 2);
1525        tb.render(|ui| {
1526            ui.text("  hello");
1527        });
1528        assert_eq!(tb.find_text("hello"), Some((2, 0)));
1529    }
1530
1531    #[test]
1532    fn find_text_scans_rows_top_to_bottom() {
1533        let mut tb = TestBackend::new(20, 3);
1534        tb.render(|ui| {
1535            let _ = ui.col(|ui| {
1536                ui.text("alpha");
1537                ui.text("beta");
1538            });
1539        });
1540        assert_eq!(tb.find_text("beta"), Some((0, 1)));
1541    }
1542
1543    #[test]
1544    fn find_text_returns_none_when_absent() {
1545        let mut tb = TestBackend::new(10, 1);
1546        tb.render(|ui| {
1547            ui.text("present");
1548        });
1549        assert_eq!(tb.find_text("missing"), None);
1550    }
1551
1552    #[test]
1553    fn find_text_empty_needle_is_none() {
1554        // Edge case: an empty needle never yields a position.
1555        let mut tb = TestBackend::new(10, 1);
1556        tb.render(|ui| {
1557            ui.text("x");
1558        });
1559        assert_eq!(tb.find_text(""), None);
1560    }
1561
1562    #[test]
1563    fn region_returns_padded_rectangle() {
1564        let mut tb = TestBackend::new(10, 3);
1565        tb.render(|ui| {
1566            let _ = ui.col(|ui| {
1567                ui.text("ab");
1568                ui.text("cd");
1569            });
1570        });
1571        // Width 3 keeps a trailing space; rows are exactly 3 wide.
1572        assert_eq!(tb.region(0, 0, 3, 2), "ab \ncd ");
1573    }
1574
1575    #[test]
1576    fn region_out_of_bounds_blank_fills() {
1577        // Edge case: a region partly past the buffer pads with spaces.
1578        let mut tb = TestBackend::new(2, 1);
1579        tb.render(|ui| {
1580            ui.text("z");
1581        });
1582        assert_eq!(tb.region(0, 0, 4, 2), "z   \n    ");
1583    }
1584
1585    #[test]
1586    fn assert_region_passes_for_match() {
1587        let mut tb = TestBackend::new(10, 3);
1588        tb.render(|ui| {
1589            let _ = ui.col(|ui| {
1590                ui.text("ab");
1591                ui.text("cd");
1592            });
1593        });
1594        tb.assert_region(0, 0, 2, 2, "ab\ncd");
1595    }
1596
1597    #[test]
1598    #[should_panic(expected = "Region (0, 0, 2x2) mismatch")]
1599    fn assert_region_panics_on_mismatch() {
1600        let mut tb = TestBackend::new(10, 3);
1601        tb.render(|ui| {
1602            let _ = ui.col(|ui| {
1603                ui.text("ab");
1604                ui.text("cd");
1605            });
1606        });
1607        tb.assert_region(0, 0, 2, 2, "ab\nXY");
1608    }
1609
1610    #[test]
1611    fn assert_styled_contains_passes_for_styled_run() {
1612        use crate::style::{Color, Modifiers};
1613        let mut tb = TestBackend::new(20, 1);
1614        tb.render(|ui| {
1615            ui.text("hi").fg(Color::Red).bold();
1616        });
1617        tb.assert_styled_contains("hi", |s| {
1618            s.fg == Some(Color::Red) && s.modifiers.contains(Modifiers::BOLD)
1619        });
1620    }
1621
1622    #[test]
1623    #[should_panic(expected = "Style predicate failed")]
1624    fn assert_styled_contains_panics_on_style_mismatch() {
1625        use crate::style::Color;
1626        let mut tb = TestBackend::new(20, 1);
1627        tb.render(|ui| {
1628            ui.text("hi").fg(Color::Red);
1629        });
1630        // Content is present but the color predicate fails.
1631        tb.assert_styled_contains("hi", |s| s.fg == Some(Color::Blue));
1632    }
1633
1634    #[test]
1635    #[should_panic(expected = "Buffer does not contain")]
1636    fn assert_styled_contains_panics_when_absent() {
1637        let mut tb = TestBackend::new(20, 1);
1638        tb.render(|ui| {
1639            ui.text("hi");
1640        });
1641        tb.assert_styled_contains("bye", |_| true);
1642    }
1643
1644    #[test]
1645    fn snapshot_is_full_width_and_height() {
1646        let mut tb = TestBackend::new(3, 2);
1647        tb.render(|ui| {
1648            ui.text("ab");
1649        });
1650        // No trailing trim, fixed width, trailing blank row preserved.
1651        assert_eq!(tb.snapshot(), "ab \n   ");
1652    }
1653
1654    #[test]
1655    fn assert_snapshot_eq_passes_ignoring_trailing_ws() {
1656        let mut tb = TestBackend::new(5, 2);
1657        tb.render(|ui| {
1658            ui.text("hi");
1659        });
1660        // Expected lacks the padding spaces — trailing whitespace is ignored.
1661        tb.assert_snapshot_eq("hi\n");
1662    }
1663
1664    #[test]
1665    #[should_panic(expected = "Snapshot mismatch")]
1666    fn assert_snapshot_eq_panics_with_diff() {
1667        let mut tb = TestBackend::new(5, 1);
1668        tb.render(|ui| {
1669            ui.text("hi");
1670        });
1671        tb.assert_snapshot_eq("bye");
1672    }
1673
1674    #[test]
1675    fn assert_snapshot_eq_diff_marks_offending_row() {
1676        // Failure-message smoke test: catch the panic and inspect the diff
1677        // markers rather than asserting only on the panic message prefix.
1678        let mut tb = TestBackend::new(5, 2);
1679        tb.render(|ui| {
1680            let _ = ui.col(|ui| {
1681                ui.text("ok");
1682                ui.text("bad");
1683            });
1684        });
1685        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1686            tb.assert_snapshot_eq("ok\nXXX");
1687        }));
1688        let err = result.expect_err("expected snapshot assertion to panic");
1689        let msg = err
1690            .downcast_ref::<String>()
1691            .map(String::as_str)
1692            .or_else(|| err.downcast_ref::<&str>().copied())
1693            .unwrap_or("");
1694        assert!(
1695            msg.contains("- XXX"),
1696            "diff should mark expected row: {msg}"
1697        );
1698        assert!(msg.contains("+ bad"), "diff should mark actual row: {msg}");
1699        // The matching first row is shown without a +/- marker.
1700        assert!(msg.contains("  ok"), "diff should echo matching row: {msg}");
1701    }
1702}