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