diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 621471ab45e..0bc7f23c45d 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -383,7 +383,7 @@ mod tests { use crate::demo::demo_app_windows::Demos; use egui::Vec2; use egui_kittest::kittest::Queryable; - use egui_kittest::snapshot::try_image_snapshot; + use egui_kittest::try_image_snapshot; use egui_kittest::wgpu::TestRenderer; use egui_kittest::Harness; @@ -406,14 +406,13 @@ mod tests { // We need to run the app for multiple frames before all windows have the right size harness.run(); harness.run(); - harness.run(); let window = harness.node().children().next().unwrap(); // TODO(lucasmerlin): Windows should probably have a label? //let window = harness.get_by_name(name); let size = window.raw_bounds().expect("window bounds").size(); - harness = harness.with_size(Vec2::new(size.width as f32, size.height as f32)); + harness.set_size(Vec2::new(size.width as f32, size.height as f32)); // We need to run the app for some more frames... harness.run(); diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 2dfcd35820e..75d74a9ba1a 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -291,7 +291,7 @@ mod tests { use super::*; use crate::View; use egui::{CentralPanel, Context, Vec2}; - use egui_kittest::snapshot::image_snapshot; + use egui_kittest::image_snapshot; use egui_kittest::wgpu::TestRenderer; use egui_kittest::Harness; @@ -303,10 +303,12 @@ mod tests { demo.ui(ui); }); }; - let mut harness = Harness::new(app) + let mut harness = Harness::builder() .with_size(Vec2::new(380.0, 550.0)) - .with_dpi(2.0); + .with_dpi(2.0) + .build(app); + // The first and second frames are slightly different, so we take the second frame harness.run(); let image = TestRenderer::new().render(&harness); diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png index 29574beb03e..2a5b8108404 100644 --- a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e3c37b58611bf14983b87320a220a8befa81273c05cbcb073399c13f6d5b630 -size 177431 +oid sha256:8ac74ddfafd31883f07a5474e30bbeafda197ef9c080d5e941122bbb6cfa4ef4 +size 177515 diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md new file mode 100644 index 00000000000..d9cfc35a2f2 --- /dev/null +++ b/crates/egui_kittest/README.md @@ -0,0 +1,35 @@ +# egui_kittest + +Ui testing library for egui, based on [kittest](https://github.com/rerun-io/kittest) (a AccessKit based testing library). + +```rust +use egui::accesskit::{Role, Toggled}; +use egui::{CentralPanel, Context, TextEdit, Vec2}; +use egui_kittest::Harness; +use kittest::Queryable; +use std::cell::RefCell; + +fn main() { + let mut checked = false; + let app = |ctx: &Context| { + CentralPanel::default().show(ctx, |ui| { + ui.checkbox(&mut checked, "Check me!"); + }); + }; + + let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app); + + let checkbox = harness.get_by_name("Check me!"); + assert_eq!(checkbox.toggled(), Some(Toggled::False)); + checkbox.click(); + + harness.run(); + + let checkbox = harness.get_by_name("Check me!"); + assert_eq!(checkbox.toggled(), Some(Toggled::True)); + + // You can even render the ui and do image snapshot tests + #[cfg(all(feature = "wgpu", feature = "snapshot"))] + egui_kittest::image_snapshot(&egui_kittest::wgpu::TestRenderer::new().render(&harness), "readme_example"); +} +``` diff --git a/crates/egui_kittest/examples/kittest.rs b/crates/egui_kittest/examples/kittest.rs deleted file mode 100644 index 6fd3e412ea3..00000000000 --- a/crates/egui_kittest/examples/kittest.rs +++ /dev/null @@ -1,50 +0,0 @@ -use egui::accesskit::{Role, Toggled}; -use egui::{CentralPanel, Context, TextEdit, Vec2}; -use egui_kittest::Harness; -use kittest::Queryable; -use std::cell::RefCell; - -fn main() { - let checked = RefCell::new(false); - let text = RefCell::new(String::new()); - let app = |ctx: &Context| { - CentralPanel::default().show(ctx, |ui| { - ui.checkbox(&mut checked.borrow_mut(), "Check me!"); - TextEdit::singleline(&mut *text.borrow_mut()) - .hint_text("Type here") - .show(ui); - }); - }; - - let mut harness = Harness::new(app).with_size(Vec2::new(200.0, 100.0)); - - harness.run(); - - harness.get_by_name("Check me!").click(); - - harness.run(); - - assert!(*checked.borrow()); - let checkbox = harness.get_by_name("Check me!"); - assert_eq!(checkbox.toggled(), Some(Toggled::True)); - - harness - .get_by_role(Role::TextInput) - .type_text("Hello, World!"); - - harness.run(); - - assert_eq!(&*text.borrow_mut(), "Hello, World!"); - assert_eq!( - harness.get_by_role(Role::TextInput).value().as_deref(), - Some("Hello, World!") - ); - - #[cfg(feature = "wgpu")] - { - let mut renderer = egui_kittest::wgpu::TestRenderer::new(); - let image = renderer.render(&harness); - - image.save("../kittest.png").unwrap(); - } -} diff --git a/crates/egui_kittest/kittest.png b/crates/egui_kittest/kittest.png deleted file mode 100644 index 3fa3303889a..00000000000 Binary files a/crates/egui_kittest/kittest.png and /dev/null differ diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs new file mode 100644 index 00000000000..6269f706a91 --- /dev/null +++ b/crates/egui_kittest/src/builder.rs @@ -0,0 +1,33 @@ +use crate::Harness; +use egui::{Pos2, Rect, Vec2}; + +pub struct HarnessBuilder { + pub(crate) screen_rect: Rect, + pub(crate) dpi: f32, +} + +impl Default for HarnessBuilder { + fn default() -> Self { + Self { + screen_rect: Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)), + dpi: 1.0, + } + } +} + +impl HarnessBuilder { + pub fn with_size(mut self, size: Vec2) -> Self { + self.screen_rect.set_width(size.x); + self.screen_rect.set_height(size.y); + self + } + + pub fn with_dpi(mut self, dpi: f32) -> Self { + self.dpi = dpi; + self + } + + pub fn build<'a>(self, app: impl FnMut(&egui::Context) + 'a) -> Harness<'a> { + Harness::from_builder(&self, app) + } +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index f4afb7b6313..6593158efb1 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -1,24 +1,30 @@ +mod builder; +#[doc = include_str!("../README.md")] mod event; #[cfg(feature = "snapshot")] -pub mod snapshot; +mod snapshot; +#[cfg(feature = "snapshot")] +pub use snapshot::*; #[cfg(feature = "wgpu")] mod texture_to_bytes; #[cfg(feature = "wgpu")] pub mod wgpu; pub use kittest; +use std::mem; use crate::event::{kittest_key_to_egui, pointer_button_to_egui}; pub use accesskit_consumer; +pub use builder::*; use egui::accesskit::NodeId; -use egui::{Event, Modifiers, Pos2, Rect, TexturesDelta, Vec2}; +use egui::{Event, Modifiers, Pos2, Rect, TexturesDelta, Vec2, ViewportId}; use kittest::{ElementState, Node, Queryable, SimulatedEvent, State}; pub struct Harness<'a> { pub ctx: egui::Context, input: egui::RawInput, - tree: Option, - output: Option, + kittest: State, + output: egui::FullOutput, texture_deltas: Vec, update_fn: Box, @@ -27,122 +33,135 @@ pub struct Harness<'a> { } impl<'a> Harness<'a> { - pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self { + pub(crate) fn from_builder( + builder: &HarnessBuilder, + mut app: impl FnMut(&egui::Context) + 'a, + ) -> Self { let ctx = egui::Context::default(); ctx.enable_accesskit(); + let mut input = egui::RawInput { + screen_rect: Some(builder.screen_rect), + ..Default::default() + }; + let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap(); + viewport.native_pixels_per_point = Some(builder.dpi); + + let mut output = ctx.run(input.clone(), &mut app); Self { update_fn: Box::new(app), ctx, - input: egui::RawInput { - screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), - ..Default::default() - }, - tree: None, - output: None, - texture_deltas: Vec::new(), + input, + kittest: State::new( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ), + texture_deltas: vec![mem::take(&mut output.textures_delta)], + output, last_mouse_pos: Pos2::ZERO, modifiers: Modifiers::NONE, } } + pub fn builder() -> HarnessBuilder { + HarnessBuilder::default() + } + + pub fn new(app: impl FnMut(&egui::Context) + 'a) -> Self { + Self::builder().build(app) + } + + /// Set the size of the window. + /// Note: If you only want to set the size once at the beginning, + /// prefer using [`HarnessBuilder::with_size`]. #[inline] - pub fn with_size(mut self, size: Vec2) -> Self { + pub fn set_size(&mut self, size: Vec2) -> &mut Self { self.input.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, size)); self } + /// Set the DPI of the window. + /// Note: If you only want to set the DPI once at the beginning, + /// prefer using [`HarnessBuilder::with_dpi`]. #[inline] - pub fn with_dpi(mut self, dpi: f32) -> Self { - self.input - .viewports - .get_mut(&self.input.viewport_id) - .unwrap() - .native_pixels_per_point = Some(dpi); + pub fn set_dpi(&mut self, dpi: f32) -> &mut Self { + self.ctx.set_pixels_per_point(dpi); self } pub fn run(&mut self) { - if let Some(tree) = &mut self.tree { - for event in tree.take_events() { - match event { - kittest::Event::ActionRequest(e) => { - self.input.events.push(Event::AccessKitActionRequest(e)); + for event in self.kittest.take_events() { + match event { + kittest::Event::ActionRequest(e) => { + self.input.events.push(Event::AccessKitActionRequest(e)); + } + kittest::Event::Simulated(e) => match e { + SimulatedEvent::CursorMoved { position } => { + self.input.events.push(Event::PointerMoved(Pos2::new( + position.x as f32, + position.y as f32, + ))); } - kittest::Event::Simulated(e) => match e { - SimulatedEvent::CursorMoved { position } => { - self.input.events.push(Event::PointerMoved(Pos2::new( - position.x as f32, - position.y as f32, - ))); + SimulatedEvent::MouseInput { state, button } => { + let button = pointer_button_to_egui(button); + if let Some(button) = button { + self.input.events.push(Event::PointerButton { + button, + modifiers: self.modifiers, + pos: self.last_mouse_pos, + pressed: matches!(state, ElementState::Pressed), + }); } - SimulatedEvent::MouseInput { state, button } => { - let button = pointer_button_to_egui(button); - if let Some(button) = button { - self.input.events.push(Event::PointerButton { - button, - modifiers: self.modifiers, - pos: self.last_mouse_pos, - pressed: matches!(state, ElementState::Pressed), - }); + } + SimulatedEvent::Ime(text) => { + self.input.events.push(Event::Text(text)); + } + SimulatedEvent::KeyInput { state, key } => { + match key { + kittest::Key::Alt => { + self.modifiers.alt = matches!(state, ElementState::Pressed); } - } - SimulatedEvent::Ime(text) => { - self.input.events.push(Event::Text(text)); - } - SimulatedEvent::KeyInput { state, key } => { - match key { - kittest::Key::Alt => { - self.modifiers.alt = matches!(state, ElementState::Pressed); - } - kittest::Key::Command => { - self.modifiers.command = matches!(state, ElementState::Pressed); - } - kittest::Key::Control => { - self.modifiers.ctrl = matches!(state, ElementState::Pressed); - } - kittest::Key::Shift => { - self.modifiers.shift = matches!(state, ElementState::Pressed); - } - _ => {} + kittest::Key::Command => { + self.modifiers.command = matches!(state, ElementState::Pressed); + } + kittest::Key::Control => { + self.modifiers.ctrl = matches!(state, ElementState::Pressed); } - let key = kittest_key_to_egui(key); - if let Some(key) = key { - self.input.events.push(Event::Key { - key, - modifiers: self.modifiers, - pressed: matches!(state, ElementState::Pressed), - repeat: false, - physical_key: None, - }); + kittest::Key::Shift => { + self.modifiers.shift = matches!(state, ElementState::Pressed); } + _ => {} } - }, - } + let key = kittest_key_to_egui(key); + if let Some(key) = key { + self.input.events.push(Event::Key { + key, + modifiers: self.modifiers, + pressed: matches!(state, ElementState::Pressed), + repeat: false, + physical_key: None, + }); + } + } + }, } } + let mut output = self.ctx.run(self.input.take(), self.update_fn.as_mut()); - if let Some(tree) = &mut self.tree { - tree.update( - output - .platform_output - .accesskit_update - .take() - .expect("AccessKit was disabled"), - ); - } else { - self.tree = Some(State::new( - output - .platform_output - .accesskit_update - .take() - .expect("AccessKit was disabled"), - )); - } - self.output = Some(output); + self.kittest.update( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ); self.texture_deltas - .push(self.output().textures_delta.clone()); + .push(mem::take(&mut output.textures_delta)); + self.output = output; } pub fn click(&mut self, id: NodeId) { @@ -193,11 +212,11 @@ impl<'a> Harness<'a> { } pub fn output(&self) -> &egui::FullOutput { - self.output.as_ref().expect("Not initialized") + &self.output } pub fn kittest_state(&self) -> &State { - self.tree.as_ref().expect("Not initialized") + &self.kittest } } diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index 7afd7b65e64..be061c05dda 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -1,10 +1,22 @@ +use image::ImageError; use std::fmt::Display; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; #[derive(Debug)] pub enum SnapshotError { - Diff { diff: i32, diff_path: PathBuf }, - MissingSnapshot { path: PathBuf }, + Diff { + diff: i32, + diff_path: PathBuf, + }, + OpenSnapshot { + path: PathBuf, + err: image::ImageError, + }, + SizeMismatch { + expected: (u32, u32), + actual: (u32, u32), + }, } impl Display for SnapshotError { @@ -16,8 +28,24 @@ impl Display for SnapshotError { "Image did not match snapshot. Diff: {diff}, {diff_path:?}" ) } - Self::MissingSnapshot { path } => { - write!(f, "Missing snapshot: {path:?}") + Self::OpenSnapshot { path, err } => match err { + ImageError::IoError(io) => match io.kind() { + ErrorKind::NotFound => { + write!(f, "Missing snapshot: {path:?}") + } + err => { + write!(f, "Error reading snapshot: {err:?}") + } + }, + err => { + write!(f, "Error decoding snapshot: {err:?}") + } + }, + Self::SizeMismatch { expected, actual } => { + write!( + f, + "Image size did not match snapshot. Expected: {expected:?}, Actual: {actual:?}" + ) } } } @@ -41,25 +69,25 @@ pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), let previous = match image::open(&path) { Ok(image) => image.to_rgba8(), Err(err) => { - println!("Error opening image: {err}"); - println!("Saving current image as {path:?}"); - current.save(&path).unwrap(); - - return Err(SnapshotError::MissingSnapshot { path }); + maybe_update_snapshot(&path, current); + return Err(SnapshotError::OpenSnapshot { path, err }); } }; + if previous.dimensions() != current.dimensions() { + maybe_update_snapshot(&path, current); + return Err(SnapshotError::SizeMismatch { + expected: previous.dimensions(), + actual: current.dimensions(), + }); + } + let result = dify::diff::get_results(previous, current.clone(), 0.1, true, None, &None, &None); if let Some((diff, result_image)) = result { result_image.save(diff_path.clone()).unwrap(); - - if std::env::var("UPDATE_SNAPSHOTS").is_ok() { - current.save(&path).unwrap(); - println!("Updated snapshot: {path:?}"); - } else { - return Err(SnapshotError::Diff { diff, diff_path }); - } + maybe_update_snapshot(&path, current); + return Err(SnapshotError::Diff { diff, diff_path }); } else { // Delete old diff if it exists std::fs::remove_file(diff_path).ok(); @@ -68,6 +96,17 @@ pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), Ok(()) } +fn should_update_snapshots() -> bool { + std::env::var("UPDATE_SNAPSHOTS").is_ok() +} + +fn maybe_update_snapshot(snapshot_path: &Path, current: &image::RgbaImage) { + if should_update_snapshots() { + current.save(snapshot_path).unwrap(); + println!("Updated snapshot: {snapshot_path:?}"); + } +} + /// Image snapshot test. /// /// # Panics diff --git a/crates/egui_kittest/tests/snapshots/readme_example.png b/crates/egui_kittest/tests/snapshots/readme_example.png new file mode 100644 index 00000000000..3c0fe1f2e3c --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/readme_example.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20600ce0f58d9593b644a69c749b1c2aebd6b6839a9a9a52b8f6d709cd660b25 +size 2624