Write documentation for new iced_test APIs

This commit is contained in:
Héctor Ramón Jiménez 2025-09-12 22:53:28 +02:00
parent 5796ba272e
commit 59e2687146
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
6 changed files with 240 additions and 21 deletions

View file

@ -1,3 +1,4 @@
//! Run your application in a headless runtime.
use crate::core;
use crate::core::mouse;
use crate::core::renderer;
@ -21,6 +22,15 @@ use crate::{Instruction, Selector};
use std::fmt;
/// A headless runtime that can run iced applications and execute
/// [instructions](crate::Instruction).
///
/// An [`Emulator`] runs its program as close as possible to the real thing.
/// It will run subscriptions and tasks in the [`Executor`](Program::Executor) of
/// the [`Program`].
///
/// If you want to run a simulation without side effects, use a [`Simulator`](crate::Simulator)
/// instead.
pub struct Emulator<P: Program> {
state: P::State,
runtime: Runtime<P::Executor, mpsc::Sender<Event<P>>, Event<P>>,
@ -34,18 +44,30 @@ pub struct Emulator<P: Program> {
pending_tasks: usize,
}
/// An emulation event.
pub enum Event<P: Program> {
/// An action that must be [performed](Emulator::perform) by the [`Emulator`].
Action(Action<P>),
/// An [`Instruction`] failed to be executed.
Failed(Instruction),
/// The [`Emulator`] is ready.
Ready,
}
pub enum Action<P: Program> {
/// An action that must be [performed](Emulator::perform) by the [`Emulator`].
pub struct Action<P: Program>(Action_<P>);
enum Action_<P: Program> {
Runtime(runtime::Action<P::Message>),
CountDown,
}
impl<P: Program + 'static> Emulator<P> {
/// Creates a new [`Emulator`] of the [`Program`] with the given [`Mode`] and [`Size`].
///
/// The [`Emulator`] will send [`Event`] notifications through the provided [`mpsc::Sender`].
///
/// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced.
pub fn new(
sender: mpsc::Sender<Event<P>>,
program: &P,
@ -55,6 +77,10 @@ impl<P: Program + 'static> Emulator<P> {
Self::with_preset(sender, program, mode, size, None)
}
/// Creates a new [`Emulator`] analogously to [`new`](Self::new), but it also takes a
/// [`program::Preset`] that will be used as the initial state.
///
/// When the [`Emulator`] has finished booting, an [`Event::Ready`] will be produced.
pub fn with_preset(
sender: mpsc::Sender<Event<P>>,
program: &P,
@ -106,6 +132,11 @@ impl<P: Program + 'static> Emulator<P> {
emulator
}
/// Updates the state of the [`Emulator`] program.
///
/// This is equivalent to calling the [`Program::update`] function,
/// resubscribing to any subscriptions, and running the resulting tasks
/// concurrently.
pub fn update(&mut self, program: &P, message: P::Message) {
let task = self
.runtime
@ -118,16 +149,24 @@ impl<P: Program + 'static> Emulator<P> {
_ => {
if let Some(stream) = task::into_stream(task) {
self.runtime.run(
stream.map(Action::Runtime).map(Event::Action).boxed(),
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.boxed(),
);
}
}
}
}
/// Performs an [`Action`].
///
/// Whenever an [`Emulator`] sends an [`Event::Action`], this
/// method must be called to proceed with the execution.
pub fn perform(&mut self, program: &P, action: Action<P>) {
match action {
Action::CountDown => {
match action.0 {
Action_::CountDown => {
if self.pending_tasks > 0 {
self.pending_tasks -= 1;
@ -136,7 +175,7 @@ impl<P: Program + 'static> Emulator<P> {
}
}
}
Action::Runtime(action) => match action {
Action_::Runtime(action) => match action {
runtime::Action::Output(message) => {
self.update(program, message);
}
@ -229,6 +268,12 @@ impl<P: Program + 'static> Emulator<P> {
}
}
/// Runs an [`Instruction`].
///
/// If the [`Instruction`] executes successfully, an [`Event::Ready`] will be
/// produced by the [`Emulator`].
///
/// Otherwise, an [`Event::Failed`] will be triggered.
pub fn run(&mut self, program: &P, instruction: Instruction) {
let mut user_interface = UserInterface::build(
program.view(&self.state, self.window),
@ -321,7 +366,7 @@ impl<P: Program + 'static> Emulator<P> {
}
}
pub fn wait_for(&mut self, task: Task<P::Message>) {
fn wait_for(&mut self, task: Task<P::Message>) {
if let Some(stream) = task::into_stream(task) {
match self.mode {
Mode::Zen => {
@ -329,10 +374,11 @@ impl<P: Program + 'static> Emulator<P> {
self.runtime.run(
stream
.map(Action::Runtime)
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.chain(stream::once(async {
Event::Action(Action::CountDown)
Event::Action(Action(Action_::CountDown))
}))
.boxed(),
);
@ -340,7 +386,8 @@ impl<P: Program + 'static> Emulator<P> {
Mode::Patient => {
self.runtime.run(
stream
.map(Action::Runtime)
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.chain(stream::once(async { Event::Ready }))
.boxed(),
@ -348,7 +395,11 @@ impl<P: Program + 'static> Emulator<P> {
}
Mode::Impatient => {
self.runtime.run(
stream.map(Action::Runtime).map(Event::Action).boxed(),
stream
.map(Action_::Runtime)
.map(Action)
.map(Event::Action)
.boxed(),
);
self.runtime.send(Event::Ready);
}
@ -358,17 +409,18 @@ impl<P: Program + 'static> Emulator<P> {
}
}
pub fn resubscribe(&mut self, program: &P) {
fn resubscribe(&mut self, program: &P) {
self.runtime
.track(subscription::into_recipes(self.runtime.enter(|| {
program.subscription(&self.state).map(|message| {
Event::Action(Action::Runtime(runtime::Action::Output(
message,
Event::Action(Action(Action_::Runtime(
runtime::Action::Output(message),
)))
})
})));
}
/// Returns the current view of the [`Emulator`].
pub fn view(
&self,
program: &P,
@ -376,24 +428,37 @@ impl<P: Program + 'static> Emulator<P> {
program.view(&self.state, self.window)
}
/// Returns the current theme of the [`Emulator`].
pub fn theme(&self, program: &P) -> Option<P::Theme> {
program.theme(&self.state, self.window)
}
/// Turns the [`Emulator`] into its internal state.
pub fn into_state(self) -> (P::State, core::window::Id) {
(self.state, self.window)
}
}
/// The strategy used by an [`Emulator`] when waiting for tasks to finish.
///
/// A [`Mode`] can be used to make an [`Emulator`] wait for side effects to finish before
/// continuing execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
/// Waits for all tasks spawned by an [`Instruction`], as well as all tasks indirectly
/// spawned by the the results of those tasks.
///
/// This is the default.
#[default]
Zen,
/// Waits only for the tasks directly spawned by an [`Instruction`].
Patient,
/// Never waits for any tasks to finish.
Impatient,
}
impl Mode {
/// A list of all the available modes.
pub const ALL: &[Self] = &[Self::Zen, Self::Patient, Self::Impatient];
}

View file

@ -10,9 +10,14 @@ use std::sync::Arc;
pub enum Error {
/// No matching widget was found for the [`Selector`](crate::Selector).
#[error("no matching widget was found for the selector: {selector}")]
SelectorNotFound { selector: String },
SelectorNotFound {
/// A description of the selector.
selector: String,
},
/// A target matched, but is not visible.
#[error("the matching target is not visible: {target:?}")]
TargetNotVisible {
/// The target
target: Arc<dyn std::fmt::Debug + Send + Sync>,
},
/// An IO operation failed.
@ -24,21 +29,30 @@ pub enum Error {
/// The encoding of some PNG image failed.
#[error("the encoding of some PNG image failed: {0}")]
PngEncodingFailed(Arc<png::EncodingError>),
/// The parsing of an [`Ice`](crate::Ice) test failed.
#[error("the ice test ({file}) is invalid: {error}")]
IceParsingFailed {
/// The path of the test.
file: PathBuf,
/// The parse error.
error: ice::ParseError,
},
/// The execution of an [`Ice`](crate::Ice) test failed.
#[error("the ice test ({file}) failed")]
IceTestingFailed {
/// The path of the test.
file: PathBuf,
/// The [`Instruction`] that failed.
instruction: Instruction,
},
/// The [`Preset`](crate::program::Preset) of a program could not be found.
#[error(
"the preset \"{name}\" does not exist (available presets: {available:?})"
)]
PresetNotFound {
/// The name of the [`Preset`](crate::program::Preset).
name: String,
/// The available set of presets.
available: Vec<String>,
},
}

View file

@ -1,17 +1,59 @@
//! A shareable, simple format of end-to-end tests.
use crate::Instruction;
use crate::core::Size;
use crate::emulator;
use crate::instruction;
/// An end-to-end test for iced applications.
///
/// Ice tests encode a certain configuration together with a sequence of instructions.
/// An ice test passes if all the instructions can be executed successfully.
///
/// Normally, ice tests are run by an [`Emulator`](crate::Emulator) in continuous
/// integration pipelines.
///
/// Ice tests can be easily run by saving them as `.ice` files in a folder and simply
/// calling [`run`](crate::run). These test files can be recorded by enabling the `tester`
/// feature flag in the root crate.
#[derive(Debug, Clone, PartialEq)]
pub struct Ice {
/// The viewport [`Size`] that must be used for the test.
pub viewport: Size,
/// The [`emulator::Mode`] that must be used for the test.
pub mode: emulator::Mode,
/// The name of the [`Preset`](crate::program::Preset) that must be used for the test.
pub preset: Option<String>,
/// The sequence of instructions of the test.
pub instructions: Vec<Instruction>,
}
impl Ice {
/// Parses an [`Ice`] test from its textual representation.
///
/// Here is an example of the [`Ice`] test syntax:
///
/// ```text
/// viewport: 500x800
/// mode: Impatient
/// preset: Empty
/// -----
/// click at "What needs to be done?"
/// type "Create the universe"
/// type enter
/// type "Make an apple pie"
/// type enter
/// expect "2 tasks left"
/// click at "Create the universe"
/// expect "1 task left"
/// click at "Make an apple pie"
/// expect "0 tasks left"
/// ```
///
/// This syntax is _very_ experimental and extremely likely to change often.
/// For this reason, it is reserved for advanced users that want to early test it.
///
/// Currently, in order to use it, you will need to earn the right and prove you understand
/// its experimental nature by reading the code!
pub fn parse(content: &str) -> Result<Self, ParseError> {
let Some((metadata, rest)) = content.split_once("-") else {
return Err(ParseError::NoMetadata);
@ -140,32 +182,64 @@ impl std::fmt::Display for Ice {
}
}
/// An error produced during [`Ice::parse`].
#[derive(Debug, Clone, thiserror::Error)]
pub enum ParseError {
/// No metadata is present.
#[error("the ice test has no metadata")]
NoMetadata,
/// The metadata is invalid.
#[error("invalid metadata in line {line}: \"{content}\"")]
InvalidMetadata { line: usize, content: String },
InvalidMetadata {
/// The number of the invalid line.
line: usize,
/// The content of the invalid line.
content: String,
},
/// The viewport is invalid.
#[error("invalid viewport in line {line}: \"{value}\"")]
InvalidViewport { line: usize, value: String },
InvalidViewport {
/// The number of the invalid line.
line: usize,
/// The invalid value.
value: String,
},
/// The [`emulator::Mode`] is invalid.
#[error("invalid mode in line {line}: \"{value}\"")]
InvalidMode { line: usize, value: String },
InvalidMode {
/// The number of the invalid line.
line: usize,
/// The invalid value.
value: String,
},
/// A metadata field is unknown.
#[error("unknown metadata field in line {line}: \"{field}\"")]
UnknownField { line: usize, field: String },
UnknownField {
/// The number of the invalid line.
line: usize,
/// The name of the unknown field.
field: String,
},
/// Viewport metadata is missing.
#[error("metadata is missing the viewport field")]
MissingViewport,
/// [`emulator::Mode`] metadata is missing.
#[error("metadata is missing the mode field")]
MissingMode,
/// An [`Instruction`] failed to parse.
#[error("invalid instruction in line {line}: {error}")]
InvalidInstruction {
/// The number of the invalid line.
line: usize,
/// The parse error.
error: instruction::ParseError,
},
}

View file

@ -1,3 +1,4 @@
//! A step in an end-to-end test.
use crate::core::keyboard;
use crate::core::mouse;
use crate::core::{Event, Point};
@ -5,13 +6,19 @@ use crate::simulator;
use std::fmt;
/// A step in an end-to-end test.
///
/// An [`Instruction`] can be run by an [`Emulator`](crate::Emulator).
#[derive(Debug, Clone, PartialEq)]
pub enum Instruction {
/// A user [`Interaction`].
Interact(Interaction),
/// A testing [`Expectation`].
Expect(Expectation),
}
impl Instruction {
/// Parses an [`Instruction`] from its textual representation.
pub fn parse(line: &str) -> Result<Self, ParseError> {
parser::run(line)
}
@ -26,13 +33,19 @@ impl fmt::Display for Instruction {
}
}
/// A user interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Interaction {
/// A mouse interaction.
Mouse(Mouse),
/// A keyboard interaction.
Keyboard(Keyboard),
}
impl Interaction {
/// Creates an [`Interaction`] from a runtime [`Event`].
///
/// This can be useful for recording tests during real usage.
pub fn from_event(event: &Event) -> Option<Self> {
Some(match event {
Event::Mouse(mouse) => Self::Mouse(match mouse {
@ -86,6 +99,17 @@ impl Interaction {
})
}
/// Merges two interactions together, if possible.
///
/// This method can turn certain sequences of interactions into a single one.
/// For instance, a mouse movement, left button press, and left button release
/// can all be merged into a single click interaction.
///
/// Merging is lossy and, therefore, it is not always desirable if you are recording
/// a test and want full reproducibility.
///
/// If the interactions cannot be merged, the `next` interaction will be
/// returned as the second element of the tuple.
pub fn merge(self, next: Self) -> (Self, Option<Self>) {
match (self, next) {
(Self::Mouse(current), Self::Mouse(next)) => {
@ -185,6 +209,10 @@ impl Interaction {
}
}
/// Returns a list of runtime events representing the [`Interaction`].
///
/// The `find_target` closure must convert a [`Target`] into its screen
/// coordinates.
pub fn events(
&self,
find_target: impl FnOnce(&Target) -> Option<Point>,
@ -256,19 +284,30 @@ impl fmt::Display for Interaction {
}
}
/// A mouse interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Mouse {
/// The mouse was moved.
Move(Target),
/// A button was pressed.
Press {
/// The button.
button: mouse::Button,
/// The location of the press.
at: Option<Target>,
},
/// A button was released.
Release {
/// The button.
button: mouse::Button,
/// The location of the release.
at: Option<Target>,
},
/// A button was clicked.
Click {
/// The button.
button: mouse::Button,
/// The location of the click.
at: Option<Target>,
},
}
@ -292,9 +331,12 @@ impl fmt::Display for Mouse {
}
}
/// The target of an interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Target {
/// A specific point of the viewport.
Point(Point),
/// A UI element containing the given text.
Text(String),
}
@ -307,11 +349,16 @@ impl fmt::Display for Target {
}
}
/// A keyboard interaction.
#[derive(Debug, Clone, PartialEq)]
pub enum Keyboard {
/// A key was pressed.
Press(Key),
/// A key was release.
Release(Key),
/// A key was "typed" (press and released).
Type(Key),
/// A bunch of text was typed.
Typewrite(String),
}
@ -334,7 +381,11 @@ impl fmt::Display for Keyboard {
}
}
/// A keyboard key.
///
/// Only a small subset of keys is supported currently!
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(missing_docs)]
pub enum Key {
Enter,
Escape,
@ -399,8 +450,13 @@ mod format {
}
}
/// A testing assertion.
///
/// Expectations are instructions that verify the current state of
/// the user interface of an application.
#[derive(Debug, Clone, PartialEq)]
pub enum Expectation {
/// Expect some element to contain some text.
Text(String),
}
@ -432,6 +488,7 @@ mod parser {
use nom::sequence::{delimited, preceded, separated_pair};
use nom::{Finish, IResult, Parser};
/// A parsing error.
#[derive(Debug, Clone, thiserror::Error)]
#[error("parse error: {0}")]
pub struct Error(nom::error::Error<String>);

View file

@ -28,7 +28,7 @@
//! #
//! # let mut counter = Counter { value: 0 };
//! # let mut ui = simulator(counter.view());
//!
//! #
//! let _ = ui.click("+");
//! let _ = ui.click("+");
//! let _ = ui.click("-");
@ -83,7 +83,6 @@
//! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)!
//!
//! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface
#![allow(missing_docs)]
pub use iced_program as program;
pub use iced_renderer as renderer;
pub use iced_runtime as runtime;
@ -107,6 +106,14 @@ pub use simulator::{Simulator, simulator};
use std::path::Path;
/// Runs an [`Ice`] test suite for the given [`Program`](program::Program).
///
/// Any `.ice` tests will be parsed from the given directory and executed in
/// an [`Emulator`] of the given [`Program`](program::Program).
///
/// Remember that an [`Emulator`] executes the real thing! Side effects _will_
/// be performed. It is up to you to ensure your tests have reproducible environments
/// by leveraging [`Preset`][program::Preset].
pub fn run(
program: impl program::Program + 'static,
tests_dir: impl AsRef<Path>,

View file

@ -1,4 +1,4 @@
//! Run a simulation of your application.
//! Run a simulation of your application without side effects.
use crate::core;
use crate::core::clipboard;
use crate::core::event;
@ -366,6 +366,7 @@ pub fn click() -> impl Iterator<Item = Event> {
.into_iter()
}
/// Returns the sequence of events of a key press.
pub fn press_key(
key: impl Into<keyboard::Key>,
text: Option<SmolStr>,
@ -384,6 +385,7 @@ pub fn press_key(
})
}
/// Returns the sequence of events of a key release.
pub fn release_key(key: impl Into<keyboard::Key>) -> Event {
let key = key.into();