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