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