From d09d5d45ae4697eef277dfe30756b91c7d802a94 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 3 Dec 2024 22:03:06 +0100 Subject: Draft `iced_test` crate and test `todos` example --- test/Cargo.toml | 21 ++++ test/src/lib.rs | 296 +++++++++++++++++++++++++++++++++++++++++++++++++++ test/src/selector.rs | 24 +++++ 3 files changed, 341 insertions(+) create mode 100644 test/Cargo.toml create mode 100644 test/src/lib.rs create mode 100644 test/src/selector.rs (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml new file mode 100644 index 00000000..c09a196d --- /dev/null +++ b/test/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "iced_test" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +categories.workspace = true +keywords.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +iced_runtime.workspace = true +iced_tiny_skia.workspace = true + +iced_renderer.workspace = true +iced_renderer.features = ["tiny-skia"] diff --git a/test/src/lib.rs b/test/src/lib.rs new file mode 100644 index 00000000..211ed4a2 --- /dev/null +++ b/test/src/lib.rs @@ -0,0 +1,296 @@ +//! Test your `iced` applications in headless mode. +#![allow(missing_docs, missing_debug_implementations)] +pub mod selector; + +pub use selector::Selector; + +use iced_renderer as renderer; +use iced_runtime as runtime; +use iced_runtime::core; +use iced_tiny_skia as tiny_skia; + +use crate::core::clipboard; +use crate::core::keyboard; +use crate::core::mouse; +use crate::core::widget; +use crate::core::{Element, Event, Font, Pixels, Rectangle, Size, SmolStr}; +use crate::renderer::Renderer; +use crate::runtime::user_interface; +use crate::runtime::UserInterface; + +pub fn interface<'a, Message, Theme>( + element: impl Into>, +) -> Interface<'a, Message, Theme, Renderer> { + let mut renderer = Renderer::Secondary(tiny_skia::Renderer::new( + Font::default(), + Pixels(16.0), + )); + + let raw = UserInterface::build( + element, + Size::new(1024.0, 1024.0), + user_interface::Cache::default(), + &mut renderer, + ); + + Interface { + raw, + renderer, + messages: Vec::new(), + } +} + +pub struct Interface<'a, Message, Theme, Renderer> { + raw: UserInterface<'a, Message, Theme, Renderer>, + renderer: Renderer, + messages: Vec, +} + +pub struct Target { + bounds: Rectangle, +} + +impl Interface<'_, Message, Theme, Renderer> +where + Renderer: core::Renderer, +{ + pub fn find( + &mut self, + selector: impl Into, + ) -> Result { + let selector = selector.into(); + + match &selector { + Selector::Id(id) => { + struct FindById<'a> { + id: &'a widget::Id, + target: Option, + } + + impl widget::Operation for FindById<'_> { + fn container( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + operate_on_children: &mut dyn FnMut( + &mut dyn widget::Operation<()>, + ), + ) { + if self.target.is_some() { + return; + } + + if Some(self.id) == id { + self.target = Some(Target { bounds }); + return; + } + + operate_on_children(self); + } + + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _content_bounds: Rectangle, + _translation: core::Vector, + _state: &mut dyn widget::operation::Scrollable, + ) { + if self.target.is_some() { + return; + } + + if Some(self.id) == id { + self.target = Some(Target { bounds }); + } + } + + fn text_input( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _state: &mut dyn widget::operation::TextInput, + ) { + if self.target.is_some() { + return; + } + + if Some(self.id) == id { + self.target = Some(Target { bounds }); + } + } + + fn text( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _text: &str, + ) { + if self.target.is_some() { + return; + } + + if Some(self.id) == id { + self.target = Some(Target { bounds }); + } + } + + fn custom( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _state: &mut dyn std::any::Any, + ) { + if self.target.is_some() { + return; + } + + if Some(self.id) == id { + self.target = Some(Target { bounds }); + } + } + } + + let mut find = FindById { id, target: None }; + + self.raw.operate(&self.renderer, &mut find); + + find.target.ok_or(Error::NotFound(selector)) + } + Selector::Text(text) => { + struct FindByText<'a> { + text: &'a str, + target: Option, + } + + impl widget::Operation for FindByText<'_> { + fn container( + &mut self, + _id: Option<&widget::Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut( + &mut dyn widget::Operation<()>, + ), + ) { + if self.target.is_some() { + return; + } + + operate_on_children(self); + } + + fn text( + &mut self, + _id: Option<&widget::Id>, + bounds: Rectangle, + text: &str, + ) { + if self.target.is_some() { + return; + } + + if self.text == text { + self.target = Some(Target { bounds }); + } + } + } + + let mut find = FindByText { text, target: None }; + + self.raw.operate(&self.renderer, &mut find); + + find.target.ok_or(Error::NotFound(selector)) + } + } + } + + pub fn click( + &mut self, + selector: impl Into, + ) -> Result { + let target = self.find(selector)?; + + let _ = self.raw.update( + &[ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(target.bounds.center()), + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + + Ok(target) + } + + pub fn typewrite(&mut self, text: impl AsRef) { + let events: Vec<_> = text + .as_ref() + .chars() + .map(|c| SmolStr::new_inline(&c.to_string())) + .flat_map(|c| { + key_press_and_release( + keyboard::Key::Character(c.clone()), + Some(c), + ) + }) + .collect(); + + let _ = self.raw.update( + &events, + mouse::Cursor::Unavailable, + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + } + + pub fn press_key(&mut self, key: impl Into) { + let _ = self.raw.update( + &key_press_and_release(key, None), + mouse::Cursor::Unavailable, + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + } + + pub fn into_messages(self) -> impl IntoIterator { + self.messages + } +} + +fn key_press_and_release( + key: impl Into, + text: Option, +) -> [Event; 2] { + let key = key.into(); + + [ + Event::Keyboard(keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: key.clone(), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::default(), + text, + }), + Event::Keyboard(keyboard::Event::KeyReleased { + key: key.clone(), + modified_key: key, + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::default(), + }), + ] +} + +#[derive(Debug, Clone)] +pub enum Error { + NotFound(Selector), +} diff --git a/test/src/selector.rs b/test/src/selector.rs new file mode 100644 index 00000000..54faa1a9 --- /dev/null +++ b/test/src/selector.rs @@ -0,0 +1,24 @@ +use crate::core::text; +use crate::core::widget; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Selector { + Id(widget::Id), + Text(text::Fragment<'static>), +} + +impl From for Selector { + fn from(id: widget::Id) -> Self { + Self::Id(id) + } +} + +impl From<&'static str> for Selector { + fn from(id: &'static str) -> Self { + Self::Id(widget::Id::new(id)) + } +} + +pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector { + Selector::Text(fragment.into_fragment()) +} -- cgit From 1aeb317f2dbfb63215e6226073e67878ffa6503b Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Fri, 6 Dec 2024 04:06:41 +0100 Subject: Add image and hash snapshot-based testing to `iced_test` --- test/Cargo.toml | 3 + test/src/lib.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 3 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index c09a196d..f6f4f45a 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -19,3 +19,6 @@ iced_tiny_skia.workspace = true iced_renderer.workspace = true iced_renderer.features = ["tiny-skia"] + +png.workspace = true +sha2.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index 211ed4a2..c9096211 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -12,15 +12,26 @@ use iced_tiny_skia as tiny_skia; use crate::core::clipboard; use crate::core::keyboard; use crate::core::mouse; +use crate::core::theme; +use crate::core::time; use crate::core::widget; +use crate::core::window; use crate::core::{Element, Event, Font, Pixels, Rectangle, Size, SmolStr}; use crate::renderer::Renderer; use crate::runtime::user_interface; use crate::runtime::UserInterface; +use std::borrow::Cow; +use std::fs; +use std::io; +use std::path::Path; +use std::sync::Arc; + pub fn interface<'a, Message, Theme>( element: impl Into>, ) -> Interface<'a, Message, Theme, Renderer> { + let size = Size::new(512.0, 512.0); + let mut renderer = Renderer::Secondary(tiny_skia::Renderer::new( Font::default(), Pixels(16.0), @@ -28,7 +39,7 @@ pub fn interface<'a, Message, Theme>( let raw = UserInterface::build( element, - Size::new(1024.0, 1024.0), + size, user_interface::Cache::default(), &mut renderer, ); @@ -36,13 +47,24 @@ pub fn interface<'a, Message, Theme>( Interface { raw, renderer, + size, messages: Vec::new(), } } +pub fn load_font(font: impl Into>) -> Result<(), Error> { + renderer::graphics::text::font_system() + .write() + .expect("Write to font system") + .load_font(font.into()); + + Ok(()) +} + pub struct Interface<'a, Message, Theme, Renderer> { raw: UserInterface<'a, Message, Theme, Renderer>, renderer: Renderer, + size: Size, messages: Vec, } @@ -50,9 +72,9 @@ pub struct Target { bounds: Rectangle, } -impl Interface<'_, Message, Theme, Renderer> +impl Interface<'_, Message, Theme, Renderer> where - Renderer: core::Renderer, + Theme: Default + theme::Base, { pub fn find( &mut self, @@ -256,11 +278,129 @@ where ); } + pub fn snapshot(&mut self) -> Result { + let theme = Theme::default(); + let base = theme.base(); + + let _ = self.raw.update( + &[Event::Window(window::Event::RedrawRequested( + time::Instant::now(), + ))], + mouse::Cursor::Unavailable, + &mut self.renderer, + &mut clipboard::Null, + &mut self.messages, + ); + + let _ = self.raw.draw( + &mut self.renderer, + &theme, + &core::renderer::Style { + text_color: base.text_color, + }, + mouse::Cursor::Unavailable, + ); + + if let Renderer::Secondary(renderer) = &mut self.renderer { + let scale_factor = 2.0; + + let viewport = renderer::graphics::Viewport::with_physical_size( + Size::new( + (self.size.width * scale_factor).round() as u32, + (self.size.height * scale_factor).round() as u32, + ), + f64::from(scale_factor), + ); + + let rgba = tiny_skia::window::compositor::screenshot::<&str>( + renderer, + &viewport, + base.background_color, + &[], + ); + + Ok(Snapshot { + screenshot: window::Screenshot::new( + rgba, + viewport.physical_size(), + viewport.scale_factor(), + ), + }) + } else { + unreachable!() + } + } + pub fn into_messages(self) -> impl IntoIterator { self.messages } } +pub struct Snapshot { + screenshot: window::Screenshot, +} + +impl Snapshot { + pub fn matches_image(&self, path: impl AsRef) -> Result { + let path = path.as_ref().with_extension("png"); + + if path.exists() { + let file = fs::File::open(&path)?; + let decoder = png::Decoder::new(file); + + let mut reader = decoder.read_info()?; + let mut bytes = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut bytes)?; + + Ok(self.screenshot.bytes == bytes[..info.buffer_size()]) + } else { + if let Some(directory) = path.parent() { + fs::create_dir_all(directory)?; + } + + let file = fs::File::create(path)?; + + let mut encoder = png::Encoder::new( + file, + self.screenshot.size.width, + self.screenshot.size.height, + ); + encoder.set_color(png::ColorType::Rgba); + + let mut writer = encoder.write_header()?; + writer.write_image_data(&self.screenshot.bytes)?; + writer.finish()?; + + Ok(true) + } + } + + pub fn matches_hash(&self, path: impl AsRef) -> Result { + use sha2::{Digest, Sha256}; + + let path = path.as_ref().with_extension("sha256"); + + let hash = { + let mut hasher = Sha256::new(); + hasher.update(&self.screenshot.bytes); + format!("{:x}", hasher.finalize()) + }; + + if path.exists() { + let saved_hash = fs::read_to_string(&path)?; + + Ok(hash == saved_hash) + } else { + if let Some(directory) = path.parent() { + fs::create_dir_all(directory)?; + } + + fs::write(path, hash)?; + Ok(true) + } + } +} + fn key_press_and_release( key: impl Into, text: Option, @@ -293,4 +433,25 @@ fn key_press_and_release( #[derive(Debug, Clone)] pub enum Error { NotFound(Selector), + IOFailed(Arc), + PngDecodingFailed(Arc), + PngEncodingFailed(Arc), +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::IOFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: png::DecodingError) -> Self { + Self::PngDecodingFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: png::EncodingError) -> Self { + Self::PngEncodingFailed(Arc::new(error)) + } } -- cgit From 6572909ab5b004176f6d261b67b4caa99f1f54bb Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Thu, 12 Dec 2024 03:14:40 +0100 Subject: Embed and use Fira Sans as default font when testing --- test/Cargo.toml | 2 +- test/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index f6f4f45a..c63a9e14 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -18,7 +18,7 @@ iced_runtime.workspace = true iced_tiny_skia.workspace = true iced_renderer.workspace = true -iced_renderer.features = ["tiny-skia"] +iced_renderer.features = ["tiny-skia", "fira-sans"] png.workspace = true sha2.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index c9096211..232b447e 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -33,7 +33,7 @@ pub fn interface<'a, Message, Theme>( let size = Size::new(512.0, 512.0); let mut renderer = Renderer::Secondary(tiny_skia::Renderer::new( - Font::default(), + Font::with_name("Fira Sans"), Pixels(16.0), )); -- cgit From 2cf4abf25bb5702635c19a22353399db8cef7be3 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Sat, 14 Dec 2024 03:49:24 +0100 Subject: Support custom renderers in `iced_test` through `renderer::Headless` trait --- test/Cargo.toml | 3 +- test/src/lib.rs | 142 ++++++++++++++++++++++++++++++++++---------------------- 2 files changed, 88 insertions(+), 57 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index c63a9e14..ff6cb38a 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -15,10 +15,9 @@ workspace = true [dependencies] iced_runtime.workspace = true -iced_tiny_skia.workspace = true iced_renderer.workspace = true -iced_renderer.features = ["tiny-skia", "fira-sans"] +iced_renderer.features = ["fira-sans"] png.workspace = true sha2.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index 232b447e..6c1d6bdc 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -7,7 +7,6 @@ pub use selector::Selector; use iced_renderer as renderer; use iced_runtime as runtime; use iced_runtime::core; -use iced_tiny_skia as tiny_skia; use crate::core::clipboard; use crate::core::keyboard; @@ -16,8 +15,7 @@ use crate::core::theme; use crate::core::time; use crate::core::widget; use crate::core::window; -use crate::core::{Element, Event, Font, Pixels, Rectangle, Size, SmolStr}; -use crate::renderer::Renderer; +use crate::core::{Element, Event, Font, Rectangle, Settings, Size, SmolStr}; use crate::runtime::user_interface; use crate::runtime::UserInterface; @@ -27,32 +25,17 @@ use std::io; use std::path::Path; use std::sync::Arc; -pub fn interface<'a, Message, Theme>( +pub fn simulator<'a, Message, Theme, Renderer>( element: impl Into>, -) -> Interface<'a, Message, Theme, Renderer> { - let size = Size::new(512.0, 512.0); - - let mut renderer = Renderer::Secondary(tiny_skia::Renderer::new( - Font::with_name("Fira Sans"), - Pixels(16.0), - )); - - let raw = UserInterface::build( - element, - size, - user_interface::Cache::default(), - &mut renderer, - ); - - Interface { - raw, - renderer, - size, - messages: Vec::new(), - } +) -> Simulator<'a, Message, Theme, Renderer> +where + Theme: Default + theme::Base, + Renderer: core::Renderer + core::renderer::Headless, +{ + Simulator::new(element) } -pub fn load_font(font: impl Into>) -> Result<(), Error> { +fn load_font(font: impl Into>) -> Result<(), Error> { renderer::graphics::text::font_system() .write() .expect("Write to font system") @@ -61,21 +44,78 @@ pub fn load_font(font: impl Into>) -> Result<(), Error> { Ok(()) } -pub struct Interface<'a, Message, Theme, Renderer> { +pub struct Simulator< + 'a, + Message, + Theme = core::Theme, + Renderer = renderer::Renderer, +> { raw: UserInterface<'a, Message, Theme, Renderer>, renderer: Renderer, - size: Size, + window_size: Size, messages: Vec, } pub struct Target { - bounds: Rectangle, + pub bounds: Rectangle, } -impl Interface<'_, Message, Theme, Renderer> +impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> where Theme: Default + theme::Base, + Renderer: core::Renderer + core::renderer::Headless, { + pub fn new( + element: impl Into>, + ) -> Self { + Self::with_settings(Settings::default(), element) + } + + pub fn with_settings( + settings: Settings, + element: impl Into>, + ) -> Self { + Self::with_settings_and_size( + settings, + window::Settings::default().size, + element, + ) + } + + pub fn with_settings_and_size( + settings: Settings, + window_size: impl Into, + element: impl Into>, + ) -> Self { + let window_size = window_size.into(); + + let default_font = match settings.default_font { + Font::DEFAULT => Font::with_name("Fira Sans"), + _ => settings.default_font, + }; + + for font in settings.fonts { + load_font(font).expect("Font must be valid"); + } + + let mut renderer = + Renderer::new(default_font, settings.default_text_size); + + let raw = UserInterface::build( + element, + window_size, + user_interface::Cache::default(), + &mut renderer, + ); + + Simulator { + raw, + renderer, + window_size, + messages: Vec::new(), + } + } + pub fn find( &mut self, selector: impl Into, @@ -301,34 +341,26 @@ where mouse::Cursor::Unavailable, ); - if let Renderer::Secondary(renderer) = &mut self.renderer { - let scale_factor = 2.0; + let scale_factor = 2.0; - let viewport = renderer::graphics::Viewport::with_physical_size( - Size::new( - (self.size.width * scale_factor).round() as u32, - (self.size.height * scale_factor).round() as u32, - ), - f64::from(scale_factor), - ); + let physical_size = Size::new( + (self.window_size.width * scale_factor).round() as u32, + (self.window_size.height * scale_factor).round() as u32, + ); - let rgba = tiny_skia::window::compositor::screenshot::<&str>( - renderer, - &viewport, - base.background_color, - &[], - ); + let rgba = self.renderer.screenshot( + physical_size, + scale_factor, + base.background_color, + ); - Ok(Snapshot { - screenshot: window::Screenshot::new( - rgba, - viewport.physical_size(), - viewport.scale_factor(), - ), - }) - } else { - unreachable!() - } + Ok(Snapshot { + screenshot: window::Screenshot::new( + rgba, + physical_size, + f64::from(scale_factor), + ), + }) } pub fn into_messages(self) -> impl IntoIterator { -- cgit From 869b44db4ec8c946c8e5eab2498bbc3a501418b1 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 01:37:00 +0100 Subject: Implement `Simulator::simulate` and polish naming --- test/src/lib.rs | 102 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 58 insertions(+), 44 deletions(-) (limited to 'test') diff --git a/test/src/lib.rs b/test/src/lib.rs index 6c1d6bdc..44594a4d 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -9,13 +9,16 @@ use iced_runtime as runtime; use iced_runtime::core; use crate::core::clipboard; +use crate::core::event; use crate::core::keyboard; use crate::core::mouse; use crate::core::theme; use crate::core::time; use crate::core::widget; use crate::core::window; -use crate::core::{Element, Event, Font, Rectangle, Settings, Size, SmolStr}; +use crate::core::{ + Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr, +}; use crate::runtime::user_interface; use crate::runtime::UserInterface; @@ -53,6 +56,7 @@ pub struct Simulator< raw: UserInterface<'a, Message, Theme, Renderer>, renderer: Renderer, window_size: Size, + cursor: mouse::Cursor, messages: Vec, } @@ -75,14 +79,14 @@ where settings: Settings, element: impl Into>, ) -> Self { - Self::with_settings_and_size( + Self::with_window_size( settings, window::Settings::default().size, element, ) } - pub fn with_settings_and_size( + pub fn with_window_size( settings: Settings, window_size: impl Into, element: impl Into>, @@ -112,6 +116,7 @@ where raw, renderer, window_size, + cursor: mouse::Cursor::Unavailable, messages: Vec::new(), } } @@ -214,7 +219,6 @@ where } let mut find = FindById { id, target: None }; - self.raw.operate(&self.renderer, &mut find); find.target.ok_or(Error::NotFound(selector)) @@ -258,7 +262,6 @@ where } let mut find = FindByText { text, target: None }; - self.raw.operate(&self.renderer, &mut find); find.target.ok_or(Error::NotFound(selector)) @@ -266,56 +269,52 @@ where } } + pub fn point_at(&mut self, position: impl Into) { + self.cursor = mouse::Cursor::Available(position.into()); + } + pub fn click( &mut self, selector: impl Into, ) -> Result { let target = self.find(selector)?; + self.point_at(target.bounds.center()); - let _ = self.raw.update( - &[ - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), - ], - mouse::Cursor::Available(target.bounds.center()), - &mut self.renderer, - &mut clipboard::Null, - &mut self.messages, - ); + let _ = self.simulate(click()); Ok(target) } - pub fn typewrite(&mut self, text: impl AsRef) { - let events: Vec<_> = text - .as_ref() - .chars() - .map(|c| SmolStr::new_inline(&c.to_string())) - .flat_map(|c| { - key_press_and_release( - keyboard::Key::Character(c.clone()), - Some(c), - ) - }) - .collect(); + pub fn tap_key(&mut self, key: impl Into) -> event::Status { + self.simulate(tap_key(key, None)) + .first() + .copied() + .unwrap_or(event::Status::Ignored) + } - let _ = self.raw.update( - &events, - mouse::Cursor::Unavailable, - &mut self.renderer, - &mut clipboard::Null, - &mut self.messages, - ); + pub fn typewrite(&mut self, text: &str) -> event::Status { + let statuses = self.simulate(typewrite(text)); + + statuses + .into_iter() + .fold(event::Status::Ignored, event::Status::merge) } - pub fn press_key(&mut self, key: impl Into) { - let _ = self.raw.update( - &key_press_and_release(key, None), - mouse::Cursor::Unavailable, + pub fn simulate( + &mut self, + events: impl IntoIterator, + ) -> Vec { + let events: Vec = events.into_iter().collect(); + + let (_state, statuses) = self.raw.update( + &events, + self.cursor, &mut self.renderer, &mut clipboard::Null, &mut self.messages, ); + + statuses } pub fn snapshot(&mut self) -> Result { @@ -326,7 +325,7 @@ where &[Event::Window(window::Event::RedrawRequested( time::Instant::now(), ))], - mouse::Cursor::Unavailable, + self.cursor, &mut self.renderer, &mut clipboard::Null, &mut self.messages, @@ -338,7 +337,7 @@ where &core::renderer::Style { text_color: base.text_color, }, - mouse::Cursor::Unavailable, + self.cursor, ); let scale_factor = 2.0; @@ -363,8 +362,8 @@ where }) } - pub fn into_messages(self) -> impl IntoIterator { - self.messages + pub fn into_messages(self) -> impl Iterator { + self.messages.into_iter() } } @@ -433,10 +432,18 @@ impl Snapshot { } } -fn key_press_and_release( +pub fn click() -> impl Iterator { + [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] + .into_iter() +} + +pub fn tap_key( key: impl Into, text: Option, -) -> [Event; 2] { +) -> impl Iterator { let key = key.into(); [ @@ -460,6 +467,13 @@ fn key_press_and_release( modifiers: keyboard::Modifiers::default(), }), ] + .into_iter() +} + +pub fn typewrite(text: &str) -> impl Iterator + '_ { + text.chars() + .map(|c| SmolStr::new_inline(&c.to_string())) + .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) } #[derive(Debug, Clone)] -- cgit From 0ad40d03387a8127b445305a1c63fa3d2ac45ed7 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 01:53:39 +0100 Subject: Reduce size of `Simulator` in `todos` test --- test/src/lib.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) (limited to 'test') diff --git a/test/src/lib.rs b/test/src/lib.rs index 44594a4d..00126e64 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -55,7 +55,7 @@ pub struct Simulator< > { raw: UserInterface<'a, Message, Theme, Renderer>, renderer: Renderer, - window_size: Size, + size: Size, cursor: mouse::Cursor, messages: Vec, } @@ -79,19 +79,15 @@ where settings: Settings, element: impl Into>, ) -> Self { - Self::with_window_size( - settings, - window::Settings::default().size, - element, - ) + Self::with_size(settings, window::Settings::default().size, element) } - pub fn with_window_size( + pub fn with_size( settings: Settings, - window_size: impl Into, + size: impl Into, element: impl Into>, ) -> Self { - let window_size = window_size.into(); + let size = size.into(); let default_font = match settings.default_font { Font::DEFAULT => Font::with_name("Fira Sans"), @@ -107,7 +103,7 @@ where let raw = UserInterface::build( element, - window_size, + size, user_interface::Cache::default(), &mut renderer, ); @@ -115,7 +111,7 @@ where Simulator { raw, renderer, - window_size, + size, cursor: mouse::Cursor::Unavailable, messages: Vec::new(), } @@ -343,8 +339,8 @@ where let scale_factor = 2.0; let physical_size = Size::new( - (self.window_size.width * scale_factor).round() as u32, - (self.window_size.height * scale_factor).round() as u32, + (self.size.width * scale_factor).round() as u32, + (self.size.height * scale_factor).round() as u32, ); let rgba = self.renderer.screenshot( -- cgit From 2f98a7e2032715409891e6d2c9f8529cfed59569 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 02:17:07 +0100 Subject: Append `env::consts::OS` to snapshot filenames --- test/Cargo.toml | 2 -- test/src/lib.rs | 28 ++++++++++++++++------------ 2 files changed, 16 insertions(+), 14 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index ff6cb38a..47e9be11 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -15,9 +15,7 @@ workspace = true [dependencies] iced_runtime.workspace = true - iced_renderer.workspace = true -iced_renderer.features = ["fira-sans"] png.workspace = true sha2.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index 00126e64..fa802dea 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -16,16 +16,15 @@ use crate::core::theme; use crate::core::time; use crate::core::widget; use crate::core::window; -use crate::core::{ - Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr, -}; +use crate::core::{Element, Event, Point, Rectangle, Settings, Size, SmolStr}; use crate::runtime::user_interface; use crate::runtime::UserInterface; use std::borrow::Cow; +use std::env; use std::fs; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; pub fn simulator<'a, Message, Theme, Renderer>( @@ -89,17 +88,12 @@ where ) -> Self { let size = size.into(); - let default_font = match settings.default_font { - Font::DEFAULT => Font::with_name("Fira Sans"), - _ => settings.default_font, - }; - for font in settings.fonts { load_font(font).expect("Font must be valid"); } let mut renderer = - Renderer::new(default_font, settings.default_text_size); + Renderer::new(settings.default_font, settings.default_text_size); let raw = UserInterface::build( element, @@ -369,7 +363,7 @@ pub struct Snapshot { impl Snapshot { pub fn matches_image(&self, path: impl AsRef) -> Result { - let path = path.as_ref().with_extension("png"); + let path = snapshot_path(path, "png"); if path.exists() { let file = fs::File::open(&path)?; @@ -405,7 +399,7 @@ impl Snapshot { pub fn matches_hash(&self, path: impl AsRef) -> Result { use sha2::{Digest, Sha256}; - let path = path.as_ref().with_extension("sha256"); + let path = snapshot_path(path, "sha256"); let hash = { let mut hasher = Sha256::new(); @@ -497,3 +491,13 @@ impl From for Error { Self::PngEncodingFailed(Arc::new(error)) } } + +fn snapshot_path(path: impl AsRef, extension: &str) -> PathBuf { + let path = path.as_ref(); + + path.with_file_name(format!( + "{file_stem}-{os}.{extension}", + file_stem = path.file_stem().unwrap_or_default().to_string_lossy(), + os = env::consts::OS, + )) +} -- cgit From 41a822c6fb6dd15c9e2246a6f0d136d83c6c7d00 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 02:27:13 +0100 Subject: Use proper hash for `creates_a_new_task` snapshot --- test/Cargo.toml | 2 ++ test/src/lib.rs | 29 ++++++++++++++--------------- 2 files changed, 16 insertions(+), 15 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index 47e9be11..ff6cb38a 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -15,7 +15,9 @@ workspace = true [dependencies] iced_runtime.workspace = true + iced_renderer.workspace = true +iced_renderer.features = ["fira-sans"] png.workspace = true sha2.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index fa802dea..e5ed040d 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -16,12 +16,13 @@ use crate::core::theme; use crate::core::time; use crate::core::widget; use crate::core::window; -use crate::core::{Element, Event, Point, Rectangle, Settings, Size, SmolStr}; +use crate::core::{ + Element, Event, Font, Point, Rectangle, Settings, Size, SmolStr, +}; use crate::runtime::user_interface; use crate::runtime::UserInterface; use std::borrow::Cow; -use std::env; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -31,7 +32,7 @@ pub fn simulator<'a, Message, Theme, Renderer>( element: impl Into>, ) -> Simulator<'a, Message, Theme, Renderer> where - Theme: Default + theme::Base, + Theme: theme::Base, Renderer: core::Renderer + core::renderer::Headless, { Simulator::new(element) @@ -65,7 +66,7 @@ pub struct Target { impl<'a, Message, Theme, Renderer> Simulator<'a, Message, Theme, Renderer> where - Theme: Default + theme::Base, + Theme: theme::Base, Renderer: core::Renderer + core::renderer::Headless, { pub fn new( @@ -88,12 +89,17 @@ where ) -> Self { let size = size.into(); + let default_font = match settings.default_font { + Font::DEFAULT => Font::with_name("Fira Sans"), + _ => settings.default_font, + }; + for font in settings.fonts { load_font(font).expect("Font must be valid"); } let mut renderer = - Renderer::new(settings.default_font, settings.default_text_size); + Renderer::new(default_font, settings.default_text_size); let raw = UserInterface::build( element, @@ -307,8 +313,7 @@ where statuses } - pub fn snapshot(&mut self) -> Result { - let theme = Theme::default(); + pub fn snapshot(&mut self, theme: &Theme) -> Result { let base = theme.base(); let _ = self.raw.update( @@ -323,7 +328,7 @@ where let _ = self.raw.draw( &mut self.renderer, - &theme, + theme, &core::renderer::Style { text_color: base.text_color, }, @@ -493,11 +498,5 @@ impl From for Error { } fn snapshot_path(path: impl AsRef, extension: &str) -> PathBuf { - let path = path.as_ref(); - - path.with_file_name(format!( - "{file_stem}-{os}.{extension}", - file_stem = path.file_stem().unwrap_or_default().to_string_lossy(), - os = env::consts::OS, - )) + path.as_ref().with_extension(extension) } -- cgit From 5220a064c5054e872fd2f8922aa83838bf066949 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 04:13:19 +0100 Subject: Write documentation for `iced_test` --- test/Cargo.toml | 1 + test/src/lib.rs | 157 +++++++++++++++++++++++++++++++++++++++++++++++---- test/src/selector.rs | 5 ++ 3 files changed, 152 insertions(+), 11 deletions(-) (limited to 'test') diff --git a/test/Cargo.toml b/test/Cargo.toml index ff6cb38a..2dd35e7f 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -21,3 +21,4 @@ iced_renderer.features = ["fira-sans"] png.workspace = true sha2.workspace = true +thiserror.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index e5ed040d..9756e427 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -1,5 +1,91 @@ //! 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 use selector::Selector; @@ -28,6 +114,9 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Creates a new [`Simulator`]. +/// +/// This is just a function version of [`Simulator::new`]. pub fn simulator<'a, Message, Theme, Renderer>( element: impl Into>, ) -> Simulator<'a, Message, Theme, Renderer> @@ -38,15 +127,8 @@ where Simulator::new(element) } -fn load_font(font: impl Into>) -> Result<(), Error> { - renderer::graphics::text::font_system() - .write() - .expect("Write to font system") - .load_font(font.into()); - - Ok(()) -} - +/// A user interface that can be interacted with and inspected programmatically. +#[allow(missing_debug_implementations)] pub struct Simulator< 'a, Message, @@ -60,7 +142,10 @@ pub struct Simulator< messages: Vec, } +/// A specific area of a [`Simulator`], normally containing a widget. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Target { + /// The bounds of the area. pub bounds: Rectangle, } @@ -69,12 +154,14 @@ where Theme: theme::Base, Renderer: core::Renderer + core::renderer::Headless, { + /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). pub fn new( element: impl Into>, ) -> Self { Self::with_settings(Settings::default(), element) } + /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). pub fn with_settings( settings: Settings, element: impl Into>, @@ -82,6 +169,7 @@ where Self::with_size(settings, window::Settings::default().size, element) } + /// Creates a new [`Simulator`] with the given [`Settings`] and size. pub fn with_size( settings: Settings, size: impl Into, @@ -117,6 +205,7 @@ where } } + /// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`]. pub fn find( &mut self, selector: impl Into, @@ -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) { 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( &mut self, selector: impl Into, @@ -281,6 +378,7 @@ where Ok(target) } + /// Simulates a key press, followed by a release, in the [`Simulator`]. pub fn tap_key(&mut self, key: impl Into) -> event::Status { self.simulate(tap_key(key, None)) .first() @@ -288,6 +386,7 @@ where .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 { let statuses = self.simulate(typewrite(text)); @@ -296,6 +395,7 @@ where .fold(event::Status::Ignored, event::Status::merge) } + /// Simulates the given raw sequence of events in the [`Simulator`]. pub fn simulate( &mut self, events: impl IntoIterator, @@ -313,6 +413,7 @@ where statuses } + /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. pub fn snapshot(&mut self, theme: &Theme) -> Result { 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 { self.messages.into_iter() } } +/// A frame of a user interface rendered by a [`Simulator`]. +#[derive(Debug, Clone)] pub struct Snapshot { screenshot: window::Screenshot, } 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) -> Result { 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) -> Result { use sha2::{Digest, Sha256}; @@ -427,6 +541,7 @@ impl Snapshot { } } +/// Returns the sequence of events of a click. pub fn click() -> impl Iterator { [ Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), @@ -435,6 +550,7 @@ pub fn click() -> impl Iterator { .into_iter() } +/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). pub fn tap_key( key: impl Into, text: Option, @@ -465,17 +581,27 @@ pub fn tap_key( .into_iter() } +/// Returns the sequence of events of typewriting the given text in a keyboard. pub fn typewrite(text: &str) -> impl Iterator + '_ { text.chars() .map(|c| SmolStr::new_inline(&c.to_string())) .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 { + /// No matching widget was found for the [`Selector`]. + #[error("no matching widget was found for the selector: {0:?}")] NotFound(Selector), + /// An IO operation failed. + #[error("an IO operation failed: {0}")] IOFailed(Arc), + /// The decoding of some PNG image failed. + #[error("the decoding of some PNG image failed: {0}")] PngDecodingFailed(Arc), + /// The encoding of some PNG image failed. + #[error("the encoding of some PNG image failed: {0}")] PngEncodingFailed(Arc), } @@ -497,6 +623,15 @@ impl From for Error { } } +fn load_font(font: impl Into>) -> Result<(), Error> { + renderer::graphics::text::font_system() + .write() + .expect("Write to font system") + .load_font(font.into()); + + Ok(()) +} + fn snapshot_path(path: impl AsRef, extension: &str) -> PathBuf { path.as_ref().with_extension(extension) } diff --git a/test/src/selector.rs b/test/src/selector.rs index 54faa1a9..7b8dcb7e 100644 --- a/test/src/selector.rs +++ b/test/src/selector.rs @@ -1,9 +1,13 @@ +//! Select widgets of a user interface. use crate::core::text; use crate::core::widget; +/// A selector describes a strategy to find a certain widget in a user interface. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Selector { + /// Find the widget with the given [`widget::Id`]. Id(widget::Id), + /// Find the widget containing the given [`text::Fragment`]. 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 { Selector::Text(fragment.into_fragment()) } -- cgit From ecd5227ef73f6273d45a359cf86e24815a53036c Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 17 Dec 2024 04:32:49 +0100 Subject: Fix redundant expression in `iced_test` documentation --- test/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'test') diff --git a/test/src/lib.rs b/test/src/lib.rs index 9756e427..73726f08 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -38,7 +38,7 @@ //! [`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 +//! We can now process any messages produced by these interactions and then assert that the final value of our counter is //! indeed `1`! //! //! ```rust,no_run -- cgit