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