iced_test/
simulator.rs

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