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