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