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