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, MouseButton, MouseEvent, MouseKind,
11};
12use crate::layout;
13use crate::rect::Rect;
14use crate::style::Theme;
15use crate::FrameState;
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 left mouse click at terminal position `(x, y)`.
76    pub fn click(mut self, x: u32, y: u32) -> Self {
77        self.events.push(Event::Mouse(MouseEvent {
78            kind: MouseKind::Down(MouseButton::Left),
79            x,
80            y,
81            modifiers: KeyModifiers::NONE,
82            pixel_x: None,
83            pixel_y: None,
84        }));
85        self
86    }
87
88    /// Append a scroll-up event at `(x, y)`.
89    pub fn scroll_up(mut self, x: u32, y: u32) -> Self {
90        self.events.push(Event::Mouse(MouseEvent {
91            kind: MouseKind::ScrollUp,
92            x,
93            y,
94            modifiers: KeyModifiers::NONE,
95            pixel_x: None,
96            pixel_y: None,
97        }));
98        self
99    }
100
101    /// Append a scroll-down event at `(x, y)`.
102    pub fn scroll_down(mut self, x: u32, y: u32) -> Self {
103        self.events.push(Event::Mouse(MouseEvent {
104            kind: MouseKind::ScrollDown,
105            x,
106            y,
107            modifiers: KeyModifiers::NONE,
108            pixel_x: None,
109            pixel_y: None,
110        }));
111        self
112    }
113
114    /// Append a bracketed-paste event.
115    pub fn paste(mut self, text: impl Into<String>) -> Self {
116        self.events.push(Event::Paste(text.into()));
117        self
118    }
119
120    /// Append a terminal resize event.
121    pub fn resize(mut self, width: u32, height: u32) -> Self {
122        self.events.push(Event::Resize(width, height));
123        self
124    }
125
126    /// Consume the builder and return the event sequence.
127    pub fn build(self) -> Vec<Event> {
128        self.events
129    }
130}
131
132impl Default for EventBuilder {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// Headless rendering backend for tests.
139///
140/// Renders a UI closure to an in-memory [`Buffer`] without a real terminal.
141/// Use [`render`](TestBackend::render) to run one frame, then inspect the
142/// output with [`line`](TestBackend::line), [`assert_contains`](TestBackend::assert_contains),
143/// or [`to_string_trimmed`](TestBackend::to_string_trimmed).
144///
145/// # Example
146///
147/// ```
148/// use slt::TestBackend;
149///
150/// let mut backend = TestBackend::new(40, 10);
151/// backend.render(|ui| {
152///     ui.text("hello");
153/// });
154/// backend.assert_contains("hello");
155/// ```
156pub struct TestBackend {
157    buffer: Buffer,
158    width: u32,
159    height: u32,
160    hook_states: Vec<Box<dyn std::any::Any>>,
161}
162
163impl TestBackend {
164    /// Create a test backend with the given terminal dimensions.
165    pub fn new(width: u32, height: u32) -> Self {
166        let area = Rect::new(0, 0, width, height);
167        Self {
168            buffer: Buffer::empty(area),
169            width,
170            height,
171            hook_states: Vec::new(),
172        }
173    }
174
175    /// Run a UI closure for one frame and render to the internal buffer.
176    pub fn render(&mut self, f: impl FnOnce(&mut Context)) {
177        let mut frame_state = FrameState {
178            hook_states: std::mem::take(&mut self.hook_states),
179            ..FrameState::default()
180        };
181        let mut ctx = Context::new(
182            Vec::new(),
183            self.width,
184            self.height,
185            &mut frame_state,
186            Theme::dark(),
187        );
188        f(&mut ctx);
189        ctx.render_notifications();
190        ctx.emit_pending_tooltips();
191        let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
192        self.hook_states = ctx.hook_states;
193        let mut deferred = ctx.deferred_draws;
194        let area = Rect::new(0, 0, self.width, self.height);
195        layout::compute(&mut tree, area);
196        self.buffer.reset();
197        layout::render(&tree, &mut self.buffer);
198        for rdr in layout::collect_raw_draw_rects(&tree) {
199            if rdr.rect.width == 0 || rdr.rect.height == 0 {
200                continue;
201            }
202            if let Some(cb) = deferred.get_mut(rdr.draw_id).and_then(|c| c.take()) {
203                self.buffer.push_clip(rdr.rect);
204                self.buffer.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
205                cb(&mut self.buffer, rdr.rect);
206                self.buffer.kitty_clip_info = None;
207                self.buffer.pop_clip();
208            }
209        }
210    }
211
212    /// Render with injected events and focus state for interaction testing.
213    pub fn render_with_events(
214        &mut self,
215        events: Vec<Event>,
216        focus_index: usize,
217        prev_focus_count: usize,
218        f: impl FnOnce(&mut Context),
219    ) {
220        let mut frame_state = FrameState {
221            hook_states: std::mem::take(&mut self.hook_states),
222            focus_index,
223            prev_focus_count,
224            ..FrameState::default()
225        };
226        let mut ctx = Context::new(
227            events,
228            self.width,
229            self.height,
230            &mut frame_state,
231            Theme::dark(),
232        );
233        f(&mut ctx);
234        ctx.process_focus_keys();
235        ctx.render_notifications();
236        ctx.emit_pending_tooltips();
237        let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
238        self.hook_states = ctx.hook_states;
239        let mut deferred = ctx.deferred_draws;
240        let area = Rect::new(0, 0, self.width, self.height);
241        layout::compute(&mut tree, area);
242        self.buffer.reset();
243        layout::render(&tree, &mut self.buffer);
244        for rdr in layout::collect_raw_draw_rects(&tree) {
245            if rdr.rect.width == 0 || rdr.rect.height == 0 {
246                continue;
247            }
248            if let Some(cb) = deferred.get_mut(rdr.draw_id).and_then(|c| c.take()) {
249                self.buffer.push_clip(rdr.rect);
250                self.buffer.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
251                cb(&mut self.buffer, rdr.rect);
252                self.buffer.kitty_clip_info = None;
253                self.buffer.pop_clip();
254            }
255        }
256    }
257
258    /// Convenience wrapper: render with events using default focus state.
259    pub fn run_with_events(&mut self, events: Vec<Event>, f: impl FnOnce(&mut crate::Context)) {
260        self.render_with_events(events, 0, 0, f);
261    }
262
263    /// Get the rendered text content of row y (trimmed trailing spaces)
264    pub fn line(&self, y: u32) -> String {
265        let mut s = String::new();
266        for x in 0..self.width {
267            s.push_str(&self.buffer.get(x, y).symbol);
268        }
269        s.trim_end().to_string()
270    }
271
272    /// Assert that row y contains `expected` as a substring
273    pub fn assert_line(&self, y: u32, expected: &str) {
274        let line = self.line(y);
275        assert_eq!(
276            line, expected,
277            "Line {y}: expected {expected:?}, got {line:?}"
278        );
279    }
280
281    /// Assert that row y contains `expected` as a substring
282    pub fn assert_line_contains(&self, y: u32, expected: &str) {
283        let line = self.line(y);
284        assert!(
285            line.contains(expected),
286            "Line {y}: expected to contain {expected:?}, got {line:?}"
287        );
288    }
289
290    /// Assert that any line in the buffer contains `expected`
291    pub fn assert_contains(&self, expected: &str) {
292        for y in 0..self.height {
293            if self.line(y).contains(expected) {
294                return;
295            }
296        }
297        let mut all_lines = String::new();
298        for y in 0..self.height {
299            all_lines.push_str(&format!("{}: {}\n", y, self.line(y)));
300        }
301        panic!("Buffer does not contain {expected:?}.\nBuffer:\n{all_lines}");
302    }
303
304    /// Access the underlying render buffer.
305    pub fn buffer(&self) -> &Buffer {
306        &self.buffer
307    }
308
309    /// Terminal width used for this backend.
310    pub fn width(&self) -> u32 {
311        self.width
312    }
313
314    /// Terminal height used for this backend.
315    pub fn height(&self) -> u32 {
316        self.height
317    }
318
319    /// Return the full rendered buffer as a multi-line string.
320    ///
321    /// Each row is trimmed of trailing spaces and joined with newlines.
322    /// Useful for snapshot testing with `insta::assert_snapshot!`.
323    pub fn to_string_trimmed(&self) -> String {
324        let mut lines = Vec::with_capacity(self.height as usize);
325        for y in 0..self.height {
326            lines.push(self.line(y));
327        }
328        while lines.last().is_some_and(|l| l.is_empty()) {
329            lines.pop();
330        }
331        lines.join("\n")
332    }
333}
334
335impl std::fmt::Display for TestBackend {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        write!(f, "{}", self.to_string_trimmed())
338    }
339}