Write documentation for iced_test

This commit is contained in:
Héctor Ramón Jiménez 2024-12-17 04:13:19 +01:00
parent 8f6f5c13b0
commit 5220a064c5
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
7 changed files with 189 additions and 13 deletions

2
Cargo.lock generated
View file

@ -1064,6 +1064,7 @@ name = "counter"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"iced", "iced",
"iced_test",
] ]
[[package]] [[package]]
@ -2517,6 +2518,7 @@ dependencies = [
"iced_runtime", "iced_runtime",
"png", "png",
"sha2", "sha2",
"thiserror 1.0.69",
] ]
[[package]] [[package]]

View file

@ -11,3 +11,6 @@ iced.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
iced.workspace = true iced.workspace = true
iced.features = ["webgl", "fira-sans"] iced.features = ["webgl", "fira-sans"]
[dev-dependencies]
iced_test.workspace = true

View file

@ -38,3 +38,32 @@ impl Counter {
.align_x(Center) .align_x(Center)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use iced_test::selector::text;
use iced_test::{simulator, Error};
#[test]
fn it_counts() -> Result<(), Error> {
let mut counter = Counter { value: 0 };
let mut ui = simulator(counter.view());
let _ = ui.click(text("Increment"));
let _ = ui.click(text("Increment"));
let _ = ui.click(text("Decrement"));
for message in ui.into_messages() {
counter.update(message);
}
assert_eq!(counter.value, 1);
let mut ui = simulator(counter.view());
assert!(ui.find(text("1")).is_ok(), "Counter should display 1!");
Ok(())
}
}

View file

@ -591,7 +591,8 @@ mod tests {
use super::*; use super::*;
use iced::{Settings, Theme}; use iced::{Settings, Theme};
use iced_test::{selector, Error, Simulator}; use iced_test::selector::text;
use iced_test::{Error, Simulator};
fn simulator(todos: &Todos) -> Simulator<Message> { fn simulator(todos: &Todos) -> Simulator<Message> {
Simulator::with_settings( Simulator::with_settings(
@ -619,7 +620,7 @@ mod tests {
} }
let mut ui = simulator(&todos); let mut ui = simulator(&todos);
let _ = ui.find(selector::text("Create the universe"))?; let _ = ui.find(text("Create the universe"))?;
let snapshot = ui.snapshot(&Theme::Dark)?; let snapshot = ui.snapshot(&Theme::Dark)?;
assert!( assert!(

View file

@ -21,3 +21,4 @@ iced_renderer.features = ["fira-sans"]
png.workspace = true png.workspace = true
sha2.workspace = true sha2.workspace = true
thiserror.workspace = true

View file

@ -1,5 +1,91 @@
//! Test your `iced` applications in headless mode. //! Test your `iced` applications in headless mode.
#![allow(missing_docs, missing_debug_implementations)] //!
//! # Basic Usage
//! Let's assume we want to test [the classical counter interface].
//!
//! First, we will want to create a [`Simulator`] of our interface:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! use iced_test::simulator;
//!
//! let mut counter = Counter { value: 0 };
//! let mut ui = simulator(counter.view());
//! ```
//!
//! Now we can simulate a user interacting with our interface. Let's use [`Simulator::click`] to click
//! the counter buttons:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! # let mut ui = simulator(counter.view());
//!
//! let _ = ui.click(text("+"));
//! let _ = ui.click(text("+"));
//! let _ = ui.click(text("-"));
//! ```
//!
//! [`Simulator::click`] takes a [`Selector`]. A [`Selector`] describes a way to query the widgets of an interface. In this case,
//! [`selector::text`] lets us select a widget by the text it contains.
//!
//! We can now process any messages produced by these interactions, and then make sure that the final value of our counter is
//! indeed `1`!
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # pub fn update(&mut self, message: ()) {}
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! # use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! # let mut ui = simulator(counter.view());
//! #
//! # let _ = ui.click(text("+"));
//! # let _ = ui.click(text("+"));
//! # let _ = ui.click(text("-"));
//! #
//! for message in ui.into_messages() {
//! counter.update(message);
//! }
//!
//! assert_eq!(counter.value, 1);
//! ```
//!
//! We can even rebuild the interface to make sure the counter _displays_ the proper value with [`Simulator::find`]:
//!
//! ```rust,no_run
//! # struct Counter { value: i64 }
//! # impl Counter {
//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() }
//! # }
//! # use iced_test::selector::text;
//! # use iced_test::simulator;
//! #
//! # let mut counter = Counter { value: 0 };
//! let mut ui = simulator(counter.view());
//!
//! assert!(ui.find(text("1")).is_ok(), "Counter should display 1!");
//! ```
//!
//! And that's it! That's the gist of testing `iced` applications!
//!
//! [`Simulator`] contains additional operations you can use to simulate more interactions—like [`tap_key`](Simulator::tap_key) or
//! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)!
//!
//! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface
pub mod selector; pub mod selector;
pub use selector::Selector; pub use selector::Selector;
@ -28,6 +114,9 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
/// Creates a new [`Simulator`].
///
/// This is just a function version of [`Simulator::new`].
pub fn simulator<'a, Message, Theme, Renderer>( pub fn simulator<'a, Message, Theme, Renderer>(
element: impl Into<Element<'a, Message, Theme, Renderer>>, element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Simulator<'a, Message, Theme, Renderer> ) -> Simulator<'a, Message, Theme, Renderer>
@ -38,15 +127,8 @@ where
Simulator::new(element) Simulator::new(element)
} }
fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> { /// A user interface that can be interacted with and inspected programmatically.
renderer::graphics::text::font_system() #[allow(missing_debug_implementations)]
.write()
.expect("Write to font system")
.load_font(font.into());
Ok(())
}
pub struct Simulator< pub struct Simulator<
'a, 'a,
Message, Message,
@ -60,7 +142,10 @@ pub struct Simulator<
messages: Vec<Message>, messages: Vec<Message>,
} }
/// A specific area of a [`Simulator`], normally containing a widget.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Target { pub struct Target {
/// The bounds of the area.
pub bounds: Rectangle, pub bounds: Rectangle,
} }
@ -69,12 +154,14 @@ where
Theme: theme::Base, Theme: theme::Base,
Renderer: core::Renderer + core::renderer::Headless, Renderer: core::Renderer + core::renderer::Headless,
{ {
/// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768).
pub fn new( pub fn new(
element: impl Into<Element<'a, Message, Theme, Renderer>>, element: impl Into<Element<'a, Message, Theme, Renderer>>,
) -> Self { ) -> Self {
Self::with_settings(Settings::default(), element) Self::with_settings(Settings::default(), element)
} }
/// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768).
pub fn with_settings( pub fn with_settings(
settings: Settings, settings: Settings,
element: impl Into<Element<'a, Message, Theme, Renderer>>, element: impl Into<Element<'a, Message, Theme, Renderer>>,
@ -82,6 +169,7 @@ where
Self::with_size(settings, window::Settings::default().size, element) Self::with_size(settings, window::Settings::default().size, element)
} }
/// Creates a new [`Simulator`] with the given [`Settings`] and size.
pub fn with_size( pub fn with_size(
settings: Settings, settings: Settings,
size: impl Into<Size>, size: impl Into<Size>,
@ -117,6 +205,7 @@ where
} }
} }
/// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`].
pub fn find( pub fn find(
&mut self, &mut self,
selector: impl Into<Selector>, selector: impl Into<Selector>,
@ -265,10 +354,18 @@ where
} }
} }
/// Points the mouse cursor at the given position in the [`Simulator`].
///
/// This does _not_ produce mouse movement events!
pub fn point_at(&mut self, position: impl Into<Point>) { pub fn point_at(&mut self, position: impl Into<Point>) {
self.cursor = mouse::Cursor::Available(position.into()); self.cursor = mouse::Cursor::Available(position.into());
} }
/// Clicks the [`Target`] found by the given [`Selector`], if any.
///
/// This consists in:
/// - Pointing the mouse cursor at the center of the [`Target`].
/// - Simulating a [`click`].
pub fn click( pub fn click(
&mut self, &mut self,
selector: impl Into<Selector>, selector: impl Into<Selector>,
@ -281,6 +378,7 @@ where
Ok(target) Ok(target)
} }
/// Simulates a key press, followed by a release, in the [`Simulator`].
pub fn tap_key(&mut self, key: impl Into<keyboard::Key>) -> event::Status { pub fn tap_key(&mut self, key: impl Into<keyboard::Key>) -> event::Status {
self.simulate(tap_key(key, None)) self.simulate(tap_key(key, None))
.first() .first()
@ -288,6 +386,7 @@ where
.unwrap_or(event::Status::Ignored) .unwrap_or(event::Status::Ignored)
} }
/// Simulates a user typing in the keyboard the given text in the [`Simulator`].
pub fn typewrite(&mut self, text: &str) -> event::Status { pub fn typewrite(&mut self, text: &str) -> event::Status {
let statuses = self.simulate(typewrite(text)); let statuses = self.simulate(typewrite(text));
@ -296,6 +395,7 @@ where
.fold(event::Status::Ignored, event::Status::merge) .fold(event::Status::Ignored, event::Status::merge)
} }
/// Simulates the given raw sequence of events in the [`Simulator`].
pub fn simulate( pub fn simulate(
&mut self, &mut self,
events: impl IntoIterator<Item = Event>, events: impl IntoIterator<Item = Event>,
@ -313,6 +413,7 @@ where
statuses statuses
} }
/// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`].
pub fn snapshot(&mut self, theme: &Theme) -> Result<Snapshot, Error> { pub fn snapshot(&mut self, theme: &Theme) -> Result<Snapshot, Error> {
let base = theme.base(); let base = theme.base();
@ -357,16 +458,24 @@ where
}) })
} }
/// Turns the [`Simulator`] into the sequence of messages produced by any interactions.
pub fn into_messages(self) -> impl Iterator<Item = Message> { pub fn into_messages(self) -> impl Iterator<Item = Message> {
self.messages.into_iter() self.messages.into_iter()
} }
} }
/// A frame of a user interface rendered by a [`Simulator`].
#[derive(Debug, Clone)]
pub struct Snapshot { pub struct Snapshot {
screenshot: window::Screenshot, screenshot: window::Screenshot,
} }
impl Snapshot { impl Snapshot {
/// Compares the [`Snapshot`] with the PNG image found in the given path, returning
/// `true` if they are identical.
///
/// If the PNG image does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> { pub fn matches_image(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
let path = snapshot_path(path, "png"); let path = snapshot_path(path, "png");
@ -401,6 +510,11 @@ impl Snapshot {
} }
} }
/// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning
/// `true` if they are identical.
///
/// If the hash file does not exist, it will be created by the [`Snapshot`] for future
/// testing and `true` will be returned.
pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> { pub fn matches_hash(&self, path: impl AsRef<Path>) -> Result<bool, Error> {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -427,6 +541,7 @@ impl Snapshot {
} }
} }
/// Returns the sequence of events of a click.
pub fn click() -> impl Iterator<Item = Event> { pub fn click() -> impl Iterator<Item = Event> {
[ [
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
@ -435,6 +550,7 @@ pub fn click() -> impl Iterator<Item = Event> {
.into_iter() .into_iter()
} }
/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key).
pub fn tap_key( pub fn tap_key(
key: impl Into<keyboard::Key>, key: impl Into<keyboard::Key>,
text: Option<SmolStr>, text: Option<SmolStr>,
@ -465,17 +581,27 @@ pub fn tap_key(
.into_iter() .into_iter()
} }
/// Returns the sequence of events of typewriting the given text in a keyboard.
pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ { pub fn typewrite(text: &str) -> impl Iterator<Item = Event> + '_ {
text.chars() text.chars()
.map(|c| SmolStr::new_inline(&c.to_string())) .map(|c| SmolStr::new_inline(&c.to_string()))
.flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c)))
} }
#[derive(Debug, Clone)] /// A test error.
#[derive(Debug, Clone, thiserror::Error)]
pub enum Error { pub enum Error {
/// No matching widget was found for the [`Selector`].
#[error("no matching widget was found for the selector: {0:?}")]
NotFound(Selector), NotFound(Selector),
/// An IO operation failed.
#[error("an IO operation failed: {0}")]
IOFailed(Arc<io::Error>), IOFailed(Arc<io::Error>),
/// The decoding of some PNG image failed.
#[error("the decoding of some PNG image failed: {0}")]
PngDecodingFailed(Arc<png::DecodingError>), PngDecodingFailed(Arc<png::DecodingError>),
/// The encoding of some PNG image failed.
#[error("the encoding of some PNG image failed: {0}")]
PngEncodingFailed(Arc<png::EncodingError>), PngEncodingFailed(Arc<png::EncodingError>),
} }
@ -497,6 +623,15 @@ impl From<png::EncodingError> for Error {
} }
} }
fn load_font(font: impl Into<Cow<'static, [u8]>>) -> Result<(), Error> {
renderer::graphics::text::font_system()
.write()
.expect("Write to font system")
.load_font(font.into());
Ok(())
}
fn snapshot_path(path: impl AsRef<Path>, extension: &str) -> PathBuf { fn snapshot_path(path: impl AsRef<Path>, extension: &str) -> PathBuf {
path.as_ref().with_extension(extension) path.as_ref().with_extension(extension)
} }

View file

@ -1,9 +1,13 @@
//! Select widgets of a user interface.
use crate::core::text; use crate::core::text;
use crate::core::widget; use crate::core::widget;
/// A selector describes a strategy to find a certain widget in a user interface.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector { pub enum Selector {
/// Find the widget with the given [`widget::Id`].
Id(widget::Id), Id(widget::Id),
/// Find the widget containing the given [`text::Fragment`].
Text(text::Fragment<'static>), Text(text::Fragment<'static>),
} }
@ -19,6 +23,7 @@ impl From<&'static str> for Selector {
} }
} }
/// Creates [`Selector`] that finds the widget containing the given text fragment.
pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector { pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector {
Selector::Text(fragment.into_fragment()) Selector::Text(fragment.into_fragment())
} }