diff --git a/test/src/emulator.rs b/test/src/emulator.rs index 4322343e..096949e2 100644 --- a/test/src/emulator.rs +++ b/test/src/emulator.rs @@ -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 { state: P::State, runtime: Runtime>, Event

>, @@ -34,18 +44,30 @@ pub struct Emulator { pending_tasks: usize, } +/// An emulation event. pub enum Event { + /// An action that must be [performed](Emulator::perform) by the [`Emulator`]. Action(Action

), + /// An [`Instruction`] failed to be executed. Failed(Instruction), + /// The [`Emulator`] is ready. Ready, } -pub enum Action { +/// An action that must be [performed](Emulator::perform) by the [`Emulator`]. +pub struct Action(Action_

); + +enum Action_ { Runtime(runtime::Action), CountDown, } impl Emulator

{ + /// 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>, program: &P, @@ -55,6 +77,10 @@ impl Emulator

{ 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>, program: &P, @@ -106,6 +132,11 @@ impl Emulator

{ 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 Emulator

{ _ => { 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

) { - match action { - Action::CountDown => { + match action.0 { + Action_::CountDown => { if self.pending_tasks > 0 { self.pending_tasks -= 1; @@ -136,7 +175,7 @@ impl Emulator

{ } } } - Action::Runtime(action) => match action { + Action_::Runtime(action) => match action { runtime::Action::Output(message) => { self.update(program, message); } @@ -229,6 +268,12 @@ impl Emulator

{ } } + /// 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 Emulator

{ } } - pub fn wait_for(&mut self, task: Task) { + fn wait_for(&mut self, task: Task) { if let Some(stream) = task::into_stream(task) { match self.mode { Mode::Zen => { @@ -329,10 +374,11 @@ impl Emulator

{ 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 Emulator

{ 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 Emulator

{ } 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 Emulator

{ } } - 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 Emulator

{ program.view(&self.state, self.window) } + /// Returns the current theme of the [`Emulator`]. pub fn theme(&self, program: &P) -> Option { 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]; } diff --git a/test/src/error.rs b/test/src/error.rs index 520fd1c6..96ec47c4 100644 --- a/test/src/error.rs +++ b/test/src/error.rs @@ -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, }, /// 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), + /// 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, }, } diff --git a/test/src/ice.rs b/test/src/ice.rs index ce0cd4a6..2777cf8e 100644 --- a/test/src/ice.rs +++ b/test/src/ice.rs @@ -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, + /// The sequence of instructions of the test. pub instructions: Vec, } 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 { 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, }, } diff --git a/test/src/instruction.rs b/test/src/instruction.rs index 6059be9c..ca6ed200 100644 --- a/test/src/instruction.rs +++ b/test/src/instruction.rs @@ -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 { 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 { 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) { 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, @@ -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, }, + /// A button was released. Release { + /// The button. button: mouse::Button, + /// The location of the release. at: Option, }, + /// A button was clicked. Click { + /// The button. button: mouse::Button, + /// The location of the click. at: Option, }, } @@ -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); diff --git a/test/src/lib.rs b/test/src/lib.rs index 1cf8d81f..5879fb04 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -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, diff --git a/test/src/simulator.rs b/test/src/simulator.rs index 009a2cc8..b578e107 100644 --- a/test/src/simulator.rs +++ b/test/src/simulator.rs @@ -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 { .into_iter() } +/// Returns the sequence of events of a key press. pub fn press_key( key: impl Into, text: Option, @@ -384,6 +385,7 @@ pub fn press_key( }) } +/// Returns the sequence of events of a key release. pub fn release_key(key: impl Into) -> Event { let key = key.into();