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