Skip to main content

iced_test/
simulator.rs

1//! Run a simulation of your application without side effects.
2use crate::core;
3use crate::core::event;
4use crate::core::keyboard;
5use crate::core::mouse;
6use crate::core::shell;
7use crate::core::theme;
8use crate::core::time;
9use crate::core::widget;
10use crate::core::window;
11use crate::core::{Element, Event, Point, Settings, Size, SmolStr};
12use crate::renderer;
13use crate::runtime::UserInterface;
14use crate::runtime::user_interface;
15use crate::selector::Bounded;
16use crate::{Error, Selector};
17
18use std::borrow::Cow;
19use std::env;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25/// A user interface that can be interacted with and inspected programmatically.
26pub struct Simulator<'a, Message, Theme = core::Theme, Renderer = renderer::Renderer> {
27    raw: UserInterface<'a, Message, Theme, Renderer>,
28    renderer: Renderer,
29    size: Size,
30    cursor: mouse::Cursor,
31    messages: Vec<Message>,
32}
33
34impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer>
35where
36    Theme: theme::Base,
37    Renderer: core::Renderer + core::renderer::Headless,
38{
39    /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768).
40    pub fn new(element: impl Into<Element<'a, Message, Theme, Renderer>>) -> Self {
41        Self::with_settings(Settings::default(), element)
42    }
43
44    /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768).
45    pub fn with_settings(
46        settings: Settings,
47        element: impl Into<Element<'a, Message, Theme, Renderer>>,
48    ) -> Self {
49        Self::with_size(settings, window::Settings::default().size, element)
50    }
51
52    /// Creates a new [`Simulator`] with the given [`Settings`] and size.
53    pub fn with_size(
54        settings: Settings,
55        size: impl Into<Size>,
56        element: impl Into<Element<'a, Message, Theme, Renderer>>,
57    ) -> Self {
58        let size = size.into();
59
60        for font in settings.fonts {
61            load_font(font).expect("Font must be valid");
62        }
63
64        let mut renderer = {
65            let backend = env::var("ICED_TEST_BACKEND").ok();
66
67            crate::futures::futures::executor::block_on(Renderer::new(
68                core::renderer::Settings {
69                    default_font: settings.default_font,
70                    default_text_size: settings.default_text_size,
71                },
72                backend.as_deref(),
73            ))
74            .expect("Create new headless renderer")
75        };
76
77        let raw = UserInterface::build(
78            element,
79            size,
80            user_interface::Cache::default(),
81            &mut renderer,
82        );
83
84        Simulator {
85            raw,
86            renderer,
87            size,
88            cursor: mouse::Cursor::Unavailable,
89            messages: Vec::new(),
90        }
91    }
92
93    /// Finds the target of the given widget [`Selector`] in the [`Simulator`].
94    pub fn find<S>(&mut self, selector: S) -> Result<S::Output, Error>
95    where
96        S: Selector + Send,
97        S::Output: Clone + Send,
98    {
99        use widget::Operation;
100
101        let description = selector.description();
102        let mut operation = selector.find();
103
104        self.raw.operate(
105            &self.renderer,
106            &mut widget::operation::black_box(&mut operation),
107        );
108
109        match operation.finish() {
110            widget::operation::Outcome::Some(output) => output.ok_or(Error::SelectorNotFound {
111                selector: description,
112            }),
113            _ => Err(Error::SelectorNotFound {
114                selector: description,
115            }),
116        }
117    }
118
119    /// Points the mouse cursor at the given position in the [`Simulator`].
120    ///
121    /// This does _not_ produce mouse movement events!
122    pub fn point_at(&mut self, position: impl Into<Point>) {
123        self.cursor = mouse::Cursor::Available(position.into());
124    }
125
126    /// Clicks the [`Bounded`] target found by the given [`Selector`], if any.
127    ///
128    /// This consists in:
129    /// - Pointing the mouse cursor at the center of the [`Bounded`] target.
130    /// - Simulating a [`click`].
131    pub fn click<S>(&mut self, selector: S) -> Result<S::Output, Error>
132    where
133        S: Selector + Send,
134        S::Output: Bounded + Clone + Send + Sync + 'static,
135    {
136        let target = self.find(selector)?;
137
138        let Some(visible_bounds) = target.visible_bounds() else {
139            return Err(Error::TargetNotVisible {
140                target: Arc::new(target),
141            });
142        };
143
144        self.point_at(visible_bounds.center());
145
146        let _ = self.simulate(click());
147
148        Ok(target)
149    }
150
151    /// Simulates a key press, followed by a release, in the [`Simulator`].
152    pub fn tap_key(&mut self, key: impl Into<keyboard::Key>) -> event::Status {
153        self.simulate(tap_key(key, None))
154            .first()
155            .copied()
156            .unwrap_or(event::Status::Ignored)
157    }
158
159    /// Simulates a user typing in the keyboard the given text in the [`Simulator`].
160    pub fn typewrite(&mut self, text: &str) -> event::Status {
161        let statuses = self.simulate(typewrite(text));
162
163        statuses
164            .into_iter()
165            .fold(event::Status::Ignored, event::Status::merge)
166    }
167
168    /// Simulates the given raw sequence of events in the [`Simulator`].
169    pub fn simulate(&mut self, events: impl IntoIterator<Item = Event>) -> Vec<event::Status> {
170        let events: Vec<Event> = events.into_iter().collect();
171
172        let (_state, statuses) = self.raw.update(
173            &window::Headless,
174            &shell::Waker::noop(),
175            &events,
176            self.cursor,
177            &mut self.renderer,
178            &mut self.messages,
179        );
180
181        statuses
182    }
183
184    /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`].
185    pub fn snapshot(&mut self, theme: &Theme) -> Result<Snapshot, Error> {
186        let base = theme.base();
187
188        let _ = self.raw.update(
189            &window::Headless,
190            &shell::Waker::noop(),
191            &[Event::Window(window::Event::RedrawRequested(
192                time::Instant::now(),
193            ))],
194            self.cursor,
195            &mut self.renderer,
196            &mut self.messages,
197        );
198
199        self.raw.draw(
200            &mut self.renderer,
201            theme,
202            &core::renderer::Style {
203                text_color: base.text_color,
204            },
205            self.cursor,
206        );
207
208        let scale_factor = 2.0;
209
210        let physical_size = Size::new(
211            (self.size.width * scale_factor).round() as u32,
212            (self.size.height * scale_factor).round() as u32,
213        );
214
215        let rgba = self
216            .renderer
217            .screenshot(physical_size, scale_factor, base.background_color);
218
219        Ok(Snapshot {
220            screenshot: window::Screenshot::new(rgba, physical_size, scale_factor),
221            renderer: self.renderer.name(),
222        })
223    }
224
225    /// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
226    pub fn into_messages(self) -> impl Iterator<Item = Message> + use<Message, Theme, Renderer> {
227        self.messages.into_iter()
228    }
229}
230
231/// A frame of a user interface rendered by a [`Simulator`].
232#[derive(Debug, Clone)]
233pub struct Snapshot {
234    screenshot: window::Screenshot,
235    renderer: String,
236}
237
238impl Snapshot {
239    /// Compares the [`Snapshot`] with the PNG image found in the given path, returning
240    /// `true` if they are identical.
241    ///
242    /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future
243    /// testing and `true` will be returned.
244    pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
245        let path = self.path(path, "png");
246
247        if path.exists() {
248            let file = fs::File::open(&path)?;
249            let decoder = png::Decoder::new(io::BufReader::new(file));
250
251            let mut reader = decoder.read_info()?;
252            let n = reader
253                .output_buffer_size()
254                .expect("snapshot should fit in memory");
255            let mut bytes = vec![0; n];
256            let info = reader.next_frame(&mut bytes)?;
257
258            Ok(self.screenshot.rgba == bytes[..info.buffer_size()])
259        } else {
260            if let Some(directory) = path.parent() {
261                fs::create_dir_all(directory)?;
262            }
263
264            let file = fs::File::create(path)?;
265
266            let mut encoder = png::Encoder::new(
267                file,
268                self.screenshot.size.width,
269                self.screenshot.size.height,
270            );
271            encoder.set_color(png::ColorType::Rgba);
272
273            let mut writer = encoder.write_header()?;
274            writer.write_image_data(&self.screenshot.rgba)?;
275            writer.finish()?;
276
277            Ok(true)
278        }
279    }
280
281    /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning
282    /// `true` if they are identical.
283    ///
284    /// If the hash file does not exist, it will be created by the [`Snapshot`] for future
285    /// testing and `true` will be returned.
286    pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
287        use sha2::{Digest, Sha256};
288
289        let path = self.path(path, "sha256");
290
291        let hash = {
292            let mut hasher = Sha256::new();
293            hasher.update(&self.screenshot.rgba);
294            format!("{:x}", hasher.finalize())
295        };
296
297        if path.exists() {
298            let saved_hash = fs::read_to_string(&path)?;
299
300            Ok(hash == saved_hash)
301        } else {
302            if let Some(directory) = path.parent() {
303                fs::create_dir_all(directory)?;
304            }
305
306            fs::write(path, hash)?;
307            Ok(true)
308        }
309    }
310
311    fn path(&self, path: impl AsRef<Path>, extension: &str) -> PathBuf {
312        let path = path.as_ref();
313
314        path.with_file_name(format!(
315            "{name}-{renderer}",
316            name = path
317                .file_stem()
318                .map(std::ffi::OsStr::to_string_lossy)
319                .unwrap_or_default(),
320            renderer = self.renderer
321        ))
322        .with_extension(extension)
323    }
324}
325
326/// Creates a new [`Simulator`].
327///
328/// This is just a function version of [`Simulator::new`].
329pub fn simulator<'a, Message, Theme, Renderer>(
330    element: impl Into<Element<'a, Message, Theme, Renderer>>,
331) -> Simulator<'a, Message, Theme, Renderer>
332where
333    Theme: theme::Base,
334    Renderer: core::Renderer + core::renderer::Headless,
335{
336    Simulator::new(element)
337}
338
339/// Returns the sequence of events of a click.
340pub fn click() -> impl Iterator<Item = Event> {
341    [
342        Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
343        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
344    ]
345    .into_iter()
346}
347
348/// Returns the sequence of events of a key press.
349pub fn press_key(key: impl Into<keyboard::Key>, text: Option<SmolStr>) -> Event {
350    let key = key.into();
351
352    Event::Keyboard(keyboard::Event::KeyPressed {
353        key: key.clone(),
354        modified_key: key,
355        physical_key: keyboard::key::Physical::Unidentified(
356            keyboard::key::NativeCode::Unidentified,
357        ),
358        location: keyboard::Location::Standard,
359        modifiers: keyboard::Modifiers::default(),
360        repeat: false,
361        text,
362    })
363}
364
365/// Returns the sequence of events of a key release.
366pub fn release_key(key: impl Into<keyboard::Key>) -> Event {
367    let key = key.into();
368
369    Event::Keyboard(keyboard::Event::KeyReleased {
370        key: key.clone(),
371        modified_key: key,
372        physical_key: keyboard::key::Physical::Unidentified(
373            keyboard::key::NativeCode::Unidentified,
374        ),
375        location: keyboard::Location::Standard,
376        modifiers: keyboard::Modifiers::default(),
377    })
378}
379
380/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key).
381pub fn tap_key(
382    key: impl Into<keyboard::Key>,
383    text: Option<SmolStr>,
384) -> impl Iterator<Item = Event> {
385    let key = key.into();
386
387    [press_key(key.clone(), text), release_key(key)].into_iter()
388}
389
390/// Returns the sequence of events of typewriting the given text in a keyboard.
391pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ {
392    text.chars()
393        .map(|c| SmolStr::new_inline(&c.to_string()))
394        .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c)))
395}
396
397fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> {
398    renderer::graphics::text::font_system()
399        .write()
400        .expect("Write to font system")
401        .load_font(font.into());
402
403    Ok(())
404}