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