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