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