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