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::font;
5use crate::core::keyboard;
6use crate::core::mouse;
7use crate::core::shell;
8use crate::core::theme;
9use crate::core::time;
10use crate::core::widget;
11use crate::core::window;
12use crate::core::{Element, Event, Point, Settings, Size, SmolStr};
13use crate::renderer;
14use crate::runtime::UserInterface;
15use crate::runtime::user_interface;
16use crate::selector::Bounded;
17use crate::{Error, Selector};
18
19use std::borrow::Cow;
20use std::env;
21use std::fs;
22use std::io;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26/// A user interface that can be interacted with and inspected programmatically.
27pub struct Simulator<'a, Message, Theme = core::Theme, Renderer = renderer::Renderer> {
28    raw: UserInterface<'a, Message, Theme, Renderer>,
29    renderer: Renderer,
30    size: Size,
31    cursor: mouse::Cursor,
32    messages: shell::Bus<Message>,
33}
34
35impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer>
36where
37    Theme: theme::Base,
38    Renderer: core::Renderer + core::renderer::Headless,
39{
40    /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768).
41    pub fn new(element: impl Into<Element<'a, Message, Theme, Renderer>>) -> Self {
42        Self::with_settings(Settings::default(), element)
43    }
44
45    /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768).
46    pub fn with_settings(
47        settings: Settings,
48        element: impl Into<Element<'a, Message, Theme, Renderer>>,
49    ) -> Self {
50        Self::with_size(settings, window::Settings::default().size, element)
51    }
52
53    /// Creates a new [`Simulator`] with the given [`Settings`] and size.
54    pub fn with_size(
55        settings: Settings,
56        size: impl Into<Size>,
57        element: impl Into<Element<'a, Message, Theme, Renderer>>,
58    ) -> Self {
59        let size = size.into();
60
61        for font in settings.fonts {
62            load_font(font).expect("Font must be valid");
63        }
64
65        let mut renderer = {
66            let backend = env::var("ICED_TEST_BACKEND").ok();
67
68            crate::futures::futures::executor::block_on(Renderer::new(
69                core::renderer::Settings {
70                    default_font: settings.default_font,
71                    default_text_size: settings.default_text_size,
72                    metrics_hinting: settings.metrics_hinting,
73                },
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: shell::Bus::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) = self.raw.update(
175            &window::Headless,
176            &shell::Waker::noop(),
177            &events,
178            self.cursor,
179            &mut self.renderer,
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            &window::Headless,
192            &shell::Waker::noop(),
193            &[Event::Window(window::Event::RedrawRequested(
194                time::Instant::now(),
195            ))],
196            self.cursor,
197            &mut self.renderer,
198            &mut self.messages,
199        );
200
201        self.raw.draw(
202            &mut self.renderer,
203            theme,
204            &core::renderer::Style {
205                text_color: base.text_color,
206            },
207            self.cursor,
208        );
209
210        let scale_factor = 2.0;
211
212        let physical_size = Size::new(
213            (self.size.width * scale_factor).round() as u32,
214            (self.size.height * scale_factor).round() as u32,
215        );
216
217        let rgba = self
218            .renderer
219            .screenshot(physical_size, scale_factor, base.background_color);
220
221        Ok(Snapshot {
222            screenshot: window::Screenshot::new(rgba, physical_size, scale_factor),
223            renderer: self.renderer.name(),
224        })
225    }
226
227    /// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
228    pub fn into_messages(self) -> impl Iterator<Item = Message> + use<Message, Theme, Renderer> {
229        self.messages.into_iter()
230    }
231}
232
233/// A frame of a user interface rendered by a [`Simulator`].
234#[derive(Debug, Clone)]
235pub struct Snapshot {
236    screenshot: window::Screenshot,
237    renderer: String,
238}
239
240impl Snapshot {
241    /// Compares the [`Snapshot`] with the PNG image found in the given path, returning
242    /// `true` if they are identical.
243    ///
244    /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future
245    /// testing and `true` will be returned.
246    pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
247        let path = self.path(path, "png");
248
249        if path.exists() {
250            let file = fs::File::open(&path)?;
251            let decoder = png::Decoder::new(io::BufReader::new(file));
252
253            let mut reader = decoder.read_info()?;
254            let n = reader
255                .output_buffer_size()
256                .expect("snapshot should fit in memory");
257            let mut bytes = vec![0; n];
258            let info = reader.next_frame(&mut bytes)?;
259
260            Ok(self.screenshot.rgba == bytes[..info.buffer_size()])
261        } else {
262            if let Some(directory) = path.parent() {
263                fs::create_dir_all(directory)?;
264            }
265
266            let file = fs::File::create(path)?;
267
268            let mut encoder = png::Encoder::new(
269                file,
270                self.screenshot.size.width,
271                self.screenshot.size.height,
272            );
273            encoder.set_color(png::ColorType::Rgba);
274
275            let mut writer = encoder.write_header()?;
276            writer.write_image_data(&self.screenshot.rgba)?;
277            writer.finish()?;
278
279            Ok(true)
280        }
281    }
282
283    /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning
284    /// `true` if they are identical.
285    ///
286    /// If the hash file does not exist, it will be created by the [`Snapshot`] for future
287    /// testing and `true` will be returned.
288    pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
289        use sha2::{Digest, Sha256};
290
291        let path = self.path(path, "sha256");
292
293        let hash = {
294            let mut hasher = Sha256::new();
295            hasher.update(&self.screenshot.rgba);
296            format!("{:x}", hasher.finalize())
297        };
298
299        if path.exists() {
300            let saved_hash = fs::read_to_string(&path)?;
301
302            Ok(hash == saved_hash)
303        } else {
304            if let Some(directory) = path.parent() {
305                fs::create_dir_all(directory)?;
306            }
307
308            fs::write(path, hash)?;
309            Ok(true)
310        }
311    }
312
313    fn path(&self, path: impl AsRef<Path>, extension: &str) -> PathBuf {
314        let path = path.as_ref();
315
316        path.with_file_name(format!(
317            "{name}-{renderer}",
318            name = path
319                .file_stem()
320                .map(std::ffi::OsStr::to_string_lossy)
321                .unwrap_or_default(),
322            renderer = self.renderer
323        ))
324        .with_extension(extension)
325    }
326}
327
328/// Creates a new [`Simulator`].
329///
330/// This is just a function version of [`Simulator::new`].
331pub fn simulator<'a, Message, Theme, Renderer>(
332    element: impl Into<Element<'a, Message, Theme, Renderer>>,
333) -> Simulator<'a, Message, Theme, Renderer>
334where
335    Theme: theme::Base,
336    Renderer: core::Renderer + core::renderer::Headless,
337{
338    Simulator::new(element)
339}
340
341/// Returns the sequence of events of a click.
342pub fn click() -> impl Iterator<Item = Event> {
343    [
344        Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
345        Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
346    ]
347    .into_iter()
348}
349
350/// Returns the sequence of events of a key press.
351pub fn press_key(key: impl Into<keyboard::Key>, text: Option<SmolStr>) -> Event {
352    let key = key.into();
353
354    Event::Keyboard(keyboard::Event::KeyPressed {
355        key: key.clone(),
356        modified_key: key,
357        physical_key: keyboard::key::Physical::Unidentified(
358            keyboard::key::NativeCode::Unidentified,
359        ),
360        location: keyboard::Location::Standard,
361        modifiers: keyboard::Modifiers::default(),
362        repeat: false,
363        text,
364    })
365}
366
367/// Returns the sequence of events of a key release.
368pub fn release_key(key: impl Into<keyboard::Key>) -> Event {
369    let key = key.into();
370
371    Event::Keyboard(keyboard::Event::KeyReleased {
372        key: key.clone(),
373        modified_key: key,
374        physical_key: keyboard::key::Physical::Unidentified(
375            keyboard::key::NativeCode::Unidentified,
376        ),
377        location: keyboard::Location::Standard,
378        modifiers: keyboard::Modifiers::default(),
379    })
380}
381
382/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key).
383pub fn tap_key(
384    key: impl Into<keyboard::Key>,
385    text: Option<SmolStr>,
386) -> impl Iterator<Item = Event> {
387    let key = key.into();
388
389    [press_key(key.clone(), text), release_key(key)].into_iter()
390}
391
392/// Returns the sequence of events of typewriting the given text in a keyboard.
393pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ {
394    text.chars()
395        .map(|c| SmolStr::new_inline(&c.to_string()))
396        .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c)))
397}
398
399fn load_font(font: Cow<'static, [u8]>) -> Result<(), font::Error> {
400    renderer::graphics::text::font_system()
401        .write()
402        .expect("Write to font system")
403        .load_font(font);
404
405    Ok(())
406}