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 area = Rect::new(0, 0, self.width, self.height);
193        layout::compute(&mut tree, area);
194        self.buffer.reset();
195        layout::render(&tree, &mut self.buffer);
196    }
197
198    /// Render with injected events and focus state for interaction testing.
199    pub fn render_with_events(
200        &mut self,
201        events: Vec<Event>,
202        focus_index: usize,
203        prev_focus_count: usize,
204        f: impl FnOnce(&mut Context),
205    ) {
206        let mut ctx = Context::new(
207            events,
208            self.width,
209            self.height,
210            0,
211            focus_index,
212            prev_focus_count,
213            Vec::new(),
214            Vec::new(),
215            Vec::new(),
216            Vec::new(),
217            Vec::new(),
218            Vec::new(),
219            std::mem::take(&mut self.hook_states),
220            false,
221            Theme::dark(),
222            None,
223            false,
224        );
225        ctx.process_focus_keys();
226        f(&mut ctx);
227        let mut tree = layout::build_tree(&ctx.commands);
228        self.hook_states = ctx.hook_states;
229        let area = Rect::new(0, 0, self.width, self.height);
230        layout::compute(&mut tree, area);
231        self.buffer.reset();
232        layout::render(&tree, &mut self.buffer);
233    }
234
235    /// Convenience wrapper: render with events using default focus state.
236    pub fn run_with_events(&mut self, events: Vec<Event>, f: impl FnOnce(&mut crate::Context)) {
237        self.render_with_events(events, 0, 0, f);
238    }
239
240    /// Get the rendered text content of row y (trimmed trailing spaces)
241    pub fn line(&self, y: u32) -> String {
242        let mut s = String::new();
243        for x in 0..self.width {
244            s.push_str(&self.buffer.get(x, y).symbol);
245        }
246        s.trim_end().to_string()
247    }
248
249    /// Assert that row y contains `expected` as a substring
250    pub fn assert_line(&self, y: u32, expected: &str) {
251        let line = self.line(y);
252        assert_eq!(
253            line, expected,
254            "Line {y}: expected {expected:?}, got {line:?}"
255        );
256    }
257
258    /// Assert that row y contains `expected` as a substring
259    pub fn assert_line_contains(&self, y: u32, expected: &str) {
260        let line = self.line(y);
261        assert!(
262            line.contains(expected),
263            "Line {y}: expected to contain {expected:?}, got {line:?}"
264        );
265    }
266
267    /// Assert that any line in the buffer contains `expected`
268    pub fn assert_contains(&self, expected: &str) {
269        for y in 0..self.height {
270            if self.line(y).contains(expected) {
271                return;
272            }
273        }
274        let mut all_lines = String::new();
275        for y in 0..self.height {
276            all_lines.push_str(&format!("{}: {}\n", y, self.line(y)));
277        }
278        panic!("Buffer does not contain {expected:?}.\nBuffer:\n{all_lines}");
279    }
280
281    /// Access the underlying render buffer.
282    pub fn buffer(&self) -> &Buffer {
283        &self.buffer
284    }
285
286    /// Terminal width used for this backend.
287    pub fn width(&self) -> u32 {
288        self.width
289    }
290
291    /// Terminal height used for this backend.
292    pub fn height(&self) -> u32 {
293        self.height
294    }
295
296    /// Return the full rendered buffer as a multi-line string.
297    ///
298    /// Each row is trimmed of trailing spaces and joined with newlines.
299    /// Useful for snapshot testing with `insta::assert_snapshot!`.
300    pub fn to_string_trimmed(&self) -> String {
301        let mut lines = Vec::with_capacity(self.height as usize);
302        for y in 0..self.height {
303            lines.push(self.line(y));
304        }
305        while lines.last().is_some_and(|l| l.is_empty()) {
306            lines.pop();
307        }
308        lines.join("\n")
309    }
310}
311
312impl std::fmt::Display for TestBackend {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(f, "{}", self.to_string_trimmed())
315    }
316}