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