diff --git a/.gitattributes b/.gitattributes index b1f5e1192e4..bffb89d4ad0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text=auto eol=lf Cargo.lock linguist-generated=false +**/tests/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1e8e31bbcf1..077580dcafb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + with: + lfs: true - uses: dtolnay/rust-toolchain@master with: @@ -60,18 +62,12 @@ jobs: - name: cargo check -p test_egui_extras_compilation run: cargo check -p test_egui_extras_compilation - - name: Test doc-tests - run: cargo test --doc --all-features - - name: cargo doc --lib run: cargo doc --lib --no-deps --all-features - name: cargo doc --document-private-items run: cargo doc --document-private-items --no-deps --all-features - - name: Test - run: cargo test --all-features - - name: clippy run: cargo clippy --all-targets --all-features -- -D warnings @@ -222,3 +218,36 @@ jobs: - name: Check hello_world run: cargo check -p hello_world + + # --------------------------------------------------------------------------- + + tests: + name: Run tests + # We run the tests on macOS because it will run with a actual GPU + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.76.0 + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + + - name: Run tests + # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) + run: cargo test + + - name: Run doc-tests + # TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature) + run: cargo test --doc + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: "**/tests/snapshots" diff --git a/.gitignore b/.gitignore index 7db0b9d06fb..887de9da3de 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ **/target **/target_ra **/target_wasm +**/tests/snapshots/**/*.diff.png +**/tests/snapshots/**/*.new.png /.*.json /.vscode /media/* diff --git a/Cargo.lock b/Cargo.lock index 47d5d30b6b8..43a25113bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -614,6 +614,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.5.0" @@ -818,6 +824,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "com" version = "0.6.0" @@ -1097,6 +1113,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dify" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11217d469eafa3b809ad84651eb9797ccbb440b4a916d5d85cb1b994e89787f6" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1304,9 +1333,12 @@ dependencies = [ "criterion", "document-features", "egui", + "egui_demo_lib", "egui_extras", + "egui_kittest", "serde", "unicode_names2", + "wgpu", ] [[package]] @@ -1348,6 +1380,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.29.1" +dependencies = [ + "dify", + "document-features", + "egui", + "egui-wgpu", + "image", + "kittest", + "pollster", + "wgpu", +] + [[package]] name = "ehttp" version = "0.5.0" @@ -1767,6 +1813,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.10" @@ -2130,12 +2185,12 @@ dependencies = [ [[package]] name = "image" -version = "0.25.0" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645" +checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" dependencies = [ "bytemuck", - "byteorder", + "byteorder-lite", "color_quant", "gif", "num-traits", @@ -2290,6 +2345,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.1.0" +source = "git+https://github.com/rerun-io/kittest?branch=main#1336a504aefd05f7e9aa7c9237ae44ba9e72acdd" +dependencies = [ + "accesskit", + "accesskit_consumer", + "parking_lot", +] + [[package]] name = "kurbo" version = "0.9.5" @@ -2299,6 +2364,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.155" diff --git a/Cargo.toml b/Cargo.toml index 9e2fac41cf8..c50a51d76e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/egui_demo_lib", "crates/egui_extras", "crates/egui_glow", + "crates/egui_kittest", "crates/egui-wgpu", "crates/egui-winit", "crates/egui", @@ -64,6 +65,7 @@ egui_extras = { version = "0.29.1", path = "crates/egui_extras", default-feature egui-wgpu = { version = "0.29.1", path = "crates/egui-wgpu", default-features = false } egui_demo_lib = { version = "0.29.1", path = "crates/egui_demo_lib", default-features = false } egui_glow = { version = "0.29.1", path = "crates/egui_glow", default-features = false } +egui_kittest = { version = "0.29.1", path = "crates/egui_kittest", default-features = false } eframe = { version = "0.29.1", path = "crates/eframe", default-features = false } ahash = { version = "0.8.11", default-features = false, features = [ @@ -73,15 +75,18 @@ ahash = { version = "0.8.11", default-features = false, features = [ backtrace = "0.3" bytemuck = "1.7.2" criterion = { version = "0.5.1", default-features = false } +dify = { version = "0.7", default-features = false } document-features = " 0.2.8" glow = "0.14" glutin = "0.32.0" glutin-winit = "0.5.0" home = "0.5.9" image = { version = "0.25", default-features = false } +kittest = { git = "https://github.com/rerun-io/kittest", version = "0.1", branch = "main"} log = { version = "0.4", features = ["std"] } nohash-hasher = "0.2" parking_lot = "0.12" +pollster = "0.3" puffin = "0.19" puffin_http = "0.16" ron = "0.8" diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index 28b0592c228..f8f1f47f686 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -55,8 +55,13 @@ serde = { workspace = true, optional = true } [dev-dependencies] -criterion.workspace = true +# when running tests we always want to use the `chrono` feature +egui_demo_lib = { workspace = true, features = ["chrono"] } +criterion.workspace = true +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +wgpu = { workspace = true, features = ["metal"] } +egui = { workspace = true, features = ["default_fonts"] } [[bench]] name = "benchmark" 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 d55cc6aff3f..6a6becd599d 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -377,3 +377,52 @@ fn file_menu_button(ui: &mut Ui) { } }); } + +#[cfg(test)] +mod tests { + use crate::demo::demo_app_windows::Demos; + use egui::Vec2; + use egui_kittest::kittest::Queryable; + use egui_kittest::Harness; + + #[test] + fn demos_should_match_snapshot() { + let demos = Demos::default(); + + let mut errors = Vec::new(); + + for mut demo in demos.demos { + // Remove the emoji from the demo name + let name = demo + .name() + .split_once(' ') + .map_or(demo.name(), |(_, name)| name); + + // Widget Gallery needs to be customized (to set a specific date) and has its own test + if name == "Widget Gallery" { + continue; + } + + let mut harness = Harness::new(|ctx| { + demo.show(ctx, &mut true); + }); + + 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.set_size(Vec2::new(size.width as f32, size.height as f32)); + + // Run the app for some more frames... + harness.run(); + + let result = harness.try_wgpu_snapshot(&format!("demos/{name}")); + if let Err(err) = result { + errors.push(err); + } + } + + assert!(errors.is_empty(), "Errors: {errors:#?}"); + } +} diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 9743306c746..96763625fca 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -112,3 +112,37 @@ impl crate::View for TextEditDemo { }); } } + +#[cfg(test)] +mod tests { + use egui::{accesskit, CentralPanel}; + use egui_kittest::kittest::{Key, Queryable}; + use egui_kittest::Harness; + + #[test] + pub fn should_type() { + let mut text = "Hello, world!".to_owned(); + let mut harness = Harness::new(move |ctx| { + CentralPanel::default().show(ctx, |ui| { + ui.text_edit_singleline(&mut text); + }); + }); + + harness.run(); + + let text_edit = harness.get_by_role(accesskit::Role::TextInput); + assert_eq!(text_edit.value().as_deref(), Some("Hello, world!")); + + text_edit.key_combination(&[Key::Command, Key::A]); + text_edit.type_text("Hi "); + + harness.run(); + harness + .get_by_role(accesskit::Role::TextInput) + .type_text("there!"); + + harness.run(); + let text_edit = harness.get_by_role(accesskit::Role::TextInput); + assert_eq!(text_edit.value().as_deref(), Some("Hi there!")); + } +} diff --git a/crates/egui_demo_lib/src/demo/widget_gallery.rs b/crates/egui_demo_lib/src/demo/widget_gallery.rs index 7be6e20c040..60c9bccf0a9 100644 --- a/crates/egui_demo_lib/src/demo/widget_gallery.rs +++ b/crates/egui_demo_lib/src/demo/widget_gallery.rs @@ -285,3 +285,31 @@ fn doc_link_label_with_crate<'a>( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::View; + use egui::{CentralPanel, Context, Vec2}; + use egui_kittest::Harness; + + #[test] + pub fn should_match_screenshot() { + let mut demo = WidgetGallery { + // If we don't set a fixed date, the snapshot test will fail. + date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()), + ..Default::default() + }; + let app = |ctx: &Context| { + CentralPanel::default().show(ctx, |ui| { + demo.ui(ui); + }); + }; + let harness = Harness::builder() + .with_size(Vec2::new(380.0, 550.0)) + .with_dpi(2.0) + .build(app); + + harness.wgpu_snapshot("widget_gallery"); + } +} diff --git "a/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" "b/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" new file mode 100644 index 00000000000..ad7d9becfaa --- /dev/null +++ "b/crates/egui_demo_lib/tests/snapshots/demos/B\303\251zier Curve.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a725aa81433f301fda4ff8a28be869366332964995d1ae4ed996591596eb7e2 +size 31461 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png new file mode 100644 index 00000000000..252c7c8f205 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Editor.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36028d85f49ee77562250214237def2b676ecc9ed413d2fd8afc473d61289ca1 +size 32761 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png new file mode 100644 index 00000000000..093b2c6a33b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Code Example.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8d4f004ee11ea68ae0f30657601b6e51403fcc3ca91fa5b8cdcb58585d8d40d +size 78318 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png new file mode 100644 index 00000000000..c29267377b7 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Context Menus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:684648bea4ef5ce138fc25dbe7576e3937a797e87f2244cb3656ff8b4c2777f5 +size 11574 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png new file mode 100644 index 00000000000..bdc4739c77f --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Dancing Strings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad38bff7cc5661be43e730e1b34c444b571b24b9f50791209496a1687610dd3d +size 20543 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png b/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png new file mode 100644 index 00000000000..035d4c13f7b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Drag and Drop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff78748f2571c49638d8fe8fdc859aaa5181758aad65498b7217551350fb9138 +size 20672 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png b/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png new file mode 100644 index 00000000000..2d48a7e085f --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Extra Viewport.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dee66004cc47f5e27aaac34d137ff005eedf70cbfa3fbe43153dfd5c09d5e18 +size 10610 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png b/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png new file mode 100644 index 00000000000..69201f86154 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Font Book.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d1086b789f1fe0a8085c86f5b6a5ae7ecb53020f385b84775d6812ebc9d74a3 +size 132349 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Frame.png b/crates/egui_demo_lib/tests/snapshots/demos/Frame.png new file mode 100644 index 00000000000..ff4d08bafe8 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08be378c01e376aab6e99ba3158519bbd7b301e815dc3447b57c9abab558977f +size 24237 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png b/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png new file mode 100644 index 00000000000..a3cab2a3097 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Highlighting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53097b2c26ebcba8b8ad657ed8e52ca40261155e96dbbfca1e8eb01fce25d290 +size 17586 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png b/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png new file mode 100644 index 00000000000..2bfbf20f91e --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Interactive Container.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9c8395e6b4287b92d85a52ca2d47750f67abeb0ad88c6b42264bfe2e62fd09d +size 22283 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png new file mode 100644 index 00000000000..80cb5b5a177 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Misc Demos.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38d21b6f8c364f86ad759e88ea1068649c23c58ded5d2953ba8ff1c83b46112f +size 63884 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png new file mode 100644 index 00000000000..23bad456c8c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Multi Touch.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83162f8c496a55230375dbc4cc636cfacf63049c913904bea9d06bdb56e63da6 +size 36282 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Painting.png b/crates/egui_demo_lib/tests/snapshots/demos/Painting.png new file mode 100644 index 00000000000..89a17e67406 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Painting.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2537c681d1ffceb5cf4bf19d11295891525c96aea0b1422ab28f133021185be0 +size 17451 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png b/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png new file mode 100644 index 00000000000..7ba225feae8 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Pan Zoom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79ce1dbf7627579d4e10de6494e34d8fd9685506d7b35cb3c9148f90f8c01366 +size 25144 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Panels.png b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png new file mode 100644 index 00000000000..585c126360c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Panels.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5068df8549ffc91028addfec6f851f12a4de80e208b50b39e4d44b6aa2c7240e +size 261946 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png new file mode 100644 index 00000000000..440a51f3871 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scrolling.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be2ac005fd5aafa293e21b162c22a09078e46d2d45b6208ce0f7841eeb05314a +size 183934 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png new file mode 100644 index 00000000000..e3a213176e9 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Sliders.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e3436906f7ac459b7f4330a286937722e78ad885ae1e90f75be566e970a8ca7 +size 116899 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Strip.png b/crates/egui_demo_lib/tests/snapshots/demos/Strip.png new file mode 100644 index 00000000000..69ec9e88230 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df7dabf726620ab5205ce153f692d1ba02365848ead7b79c95b873d5121d52a6 +size 25850 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Table.png b/crates/egui_demo_lib/tests/snapshots/demos/Table.png new file mode 100644 index 00000000000..9f3618ba039 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Table.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae6c2e3aad43cfad3322340ff7045ec50ba01d58feb7b8acc5ba062a8a5c9ab8 +size 70230 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png b/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png new file mode 100644 index 00000000000..ff972ae484c --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Text Layout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec0c2efff75cb8d621f5a4ea59f9fa8d3076521ca34f4499e07fb9dc8681d7ba +size 65916 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png new file mode 100644 index 00000000000..7af3611b924 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c04aee0a3a77a3691bb601a93871117500be917e0896138fda43251454ec04c2 +size 20988 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png new file mode 100644 index 00000000000..4e7d8a9230b --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Tooltips.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:814d863deaa4fa029044da1783db87744f0d82e874edd6cbab16e712ed8715aa +size 59881 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png b/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png new file mode 100644 index 00000000000..a635cdfabd0 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Undo Redo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e682f5cb9ecb1bdf89281c2ba1612078e70e97f28c76facc64d717e4015ced6a +size 12977 diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png new file mode 100644 index 00000000000..f28774030aa --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Window Options.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15acfb041cc53ef9bd966d6edd53a6b692cdb645ae5cf34bc20e70d403371c30 +size 34809 diff --git a/crates/egui_demo_lib/tests/snapshots/widget_gallery.png b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png new file mode 100644 index 00000000000..273b85a6303 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/widget_gallery.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc632962f8894c4f20a48c9b9e57d60470f3f83ef7f19d05854dba718610a2f +size 161820 diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml new file mode 100644 index 00000000000..5063b59658b --- /dev/null +++ b/crates/egui_kittest/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "egui_kittest" +version.workspace = true +authors = ["Lucas Meurer ", "Emil Ernerfeldt "] +description = "Testing library for egui based on kittest and AccessKit" +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui" +license.workspace = true +readme = "./README.md" +repository = "https://github.com/emilk/egui" +categories = ["gui", "development-tools::testing", "accessibility"] +keywords = ["gui", "immediate", "egui", "testing", "accesskit"] +include = ["../LICENSE-APACHE", "../LICENSE-MIT", "**/*.rs", "Cargo.toml"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +# Adds a wgpu-based test renderer. +wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image"] + +# Adds a dify-based image snapshot utility. +snapshot = ["dep:dify", "dep:image", "image/png"] + + +[dependencies] +kittest.workspace = true +egui = { workspace = true, features = ["accesskit"] } + +# wgpu dependencies +egui-wgpu = { workspace = true, optional = true } +pollster = { workspace = true, optional = true } +image = { workspace = true, optional = true } + +# snapshot dependencies +dify = { workspace = true, optional = true } + +## Enable this when generating docs. +document-features = { workspace = true, optional = true } + +[dev-dependencies] +wgpu = { workspace = true, features = ["metal"] } +image = { workspace = true, features = ["png"] } +egui = { workspace = true, features = ["default_fonts"] } + +[lints] +workspace = true diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md new file mode 100644 index 00000000000..86ccf551506 --- /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) (an [AccessKit](https://github.com/AccessKit/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"))] + harness.wgpu_snapshot("readme_example"); +} +``` diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs new file mode 100644 index 00000000000..bf1daf3622b --- /dev/null +++ b/crates/egui_kittest/src/builder.rs @@ -0,0 +1,55 @@ +use crate::Harness; +use egui::{Pos2, Rect, Vec2}; + +/// Builder for [`Harness`]. +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 { + /// Set the size of the window. + #[inline] + pub fn with_size(mut self, size: impl Into) -> Self { + let size = size.into(); + self.screen_rect.set_width(size.x); + self.screen_rect.set_height(size.y); + self + } + + /// Set the DPI of the window. + #[inline] + pub fn with_dpi(mut self, dpi: f32) -> Self { + self.dpi = dpi; + self + } + + /// Create a new Harness with the given app closure. + /// + /// The ui closure will immediately be called once to create the initial ui. + /// + /// # Example + /// ```rust + /// # use egui::CentralPanel; + /// # use egui_kittest::Harness; + /// let mut harness = Harness::builder() + /// .with_size(egui::Vec2::new(300.0, 200.0)) + /// .build(|ctx| { + /// CentralPanel::default().show(ctx, |ui| { + /// ui.label("Hello, world!"); + /// }); + /// }); + /// ``` + 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/event.rs b/crates/egui_kittest/src/event.rs new file mode 100644 index 00000000000..5ac07488d0c --- /dev/null +++ b/crates/egui_kittest/src/event.rs @@ -0,0 +1,182 @@ +use egui::Event::PointerButton; +use egui::{Event, Modifiers, Pos2}; +use kittest::{ElementState, MouseButton, SimulatedEvent}; + +#[derive(Default)] +pub(crate) struct EventState { + modifiers: Modifiers, + last_mouse_pos: Pos2, +} + +impl EventState { + pub fn kittest_event_to_egui(&mut self, event: kittest::Event) -> Option { + match event { + kittest::Event::ActionRequest(e) => Some(Event::AccessKitActionRequest(e)), + kittest::Event::Simulated(e) => match e { + SimulatedEvent::CursorMoved { position } => { + self.last_mouse_pos = Pos2::new(position.x as f32, position.y as f32); + Some(Event::PointerMoved(Pos2::new( + position.x as f32, + position.y as f32, + ))) + } + SimulatedEvent::MouseInput { state, button } => { + pointer_button_to_egui(button).map(|button| PointerButton { + button, + modifiers: self.modifiers, + pos: self.last_mouse_pos, + pressed: matches!(state, ElementState::Pressed), + }) + } + SimulatedEvent::Ime(text) => Some(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_to_egui(key).map(|key| Event::Key { + key, + modifiers: self.modifiers, + pressed: matches!(state, ElementState::Pressed), + repeat: false, + physical_key: None, + }) + } + }, + } + } +} + +pub fn kittest_key_to_egui(value: kittest::Key) -> Option { + use egui::Key as EKey; + use kittest::Key; + match value { + Key::ArrowDown => Some(EKey::ArrowDown), + Key::ArrowLeft => Some(EKey::ArrowLeft), + Key::ArrowRight => Some(EKey::ArrowRight), + Key::ArrowUp => Some(EKey::ArrowUp), + Key::Escape => Some(EKey::Escape), + Key::Tab => Some(EKey::Tab), + Key::Backspace => Some(EKey::Backspace), + Key::Enter => Some(EKey::Enter), + Key::Space => Some(EKey::Space), + Key::Insert => Some(EKey::Insert), + Key::Delete => Some(EKey::Delete), + Key::Home => Some(EKey::Home), + Key::End => Some(EKey::End), + Key::PageUp => Some(EKey::PageUp), + Key::PageDown => Some(EKey::PageDown), + Key::Copy => Some(EKey::Copy), + Key::Cut => Some(EKey::Cut), + Key::Paste => Some(EKey::Paste), + Key::Colon => Some(EKey::Colon), + Key::Comma => Some(EKey::Comma), + Key::Backslash => Some(EKey::Backslash), + Key::Slash => Some(EKey::Slash), + Key::Pipe => Some(EKey::Pipe), + Key::Questionmark => Some(EKey::Questionmark), + Key::OpenBracket => Some(EKey::OpenBracket), + Key::CloseBracket => Some(EKey::CloseBracket), + Key::Backtick => Some(EKey::Backtick), + Key::Minus => Some(EKey::Minus), + Key::Period => Some(EKey::Period), + Key::Plus => Some(EKey::Plus), + Key::Equals => Some(EKey::Equals), + Key::Semicolon => Some(EKey::Semicolon), + Key::Quote => Some(EKey::Quote), + Key::Num0 => Some(EKey::Num0), + Key::Num1 => Some(EKey::Num1), + Key::Num2 => Some(EKey::Num2), + Key::Num3 => Some(EKey::Num3), + Key::Num4 => Some(EKey::Num4), + Key::Num5 => Some(EKey::Num5), + Key::Num6 => Some(EKey::Num6), + Key::Num7 => Some(EKey::Num7), + Key::Num8 => Some(EKey::Num8), + Key::Num9 => Some(EKey::Num9), + Key::A => Some(EKey::A), + Key::B => Some(EKey::B), + Key::C => Some(EKey::C), + Key::D => Some(EKey::D), + Key::E => Some(EKey::E), + Key::F => Some(EKey::F), + Key::G => Some(EKey::G), + Key::H => Some(EKey::H), + Key::I => Some(EKey::I), + Key::J => Some(EKey::J), + Key::K => Some(EKey::K), + Key::L => Some(EKey::L), + Key::M => Some(EKey::M), + Key::N => Some(EKey::N), + Key::O => Some(EKey::O), + Key::P => Some(EKey::P), + Key::Q => Some(EKey::Q), + Key::R => Some(EKey::R), + Key::S => Some(EKey::S), + Key::T => Some(EKey::T), + Key::U => Some(EKey::U), + Key::V => Some(EKey::V), + Key::W => Some(EKey::W), + Key::X => Some(EKey::X), + Key::Y => Some(EKey::Y), + Key::Z => Some(EKey::Z), + Key::F1 => Some(EKey::F1), + Key::F2 => Some(EKey::F2), + Key::F3 => Some(EKey::F3), + Key::F4 => Some(EKey::F4), + Key::F5 => Some(EKey::F5), + Key::F6 => Some(EKey::F6), + Key::F7 => Some(EKey::F7), + Key::F8 => Some(EKey::F8), + Key::F9 => Some(EKey::F9), + Key::F10 => Some(EKey::F10), + Key::F11 => Some(EKey::F11), + Key::F12 => Some(EKey::F12), + Key::F13 => Some(EKey::F13), + Key::F14 => Some(EKey::F14), + Key::F15 => Some(EKey::F15), + Key::F16 => Some(EKey::F16), + Key::F17 => Some(EKey::F17), + Key::F18 => Some(EKey::F18), + Key::F19 => Some(EKey::F19), + Key::F20 => Some(EKey::F20), + Key::F21 => Some(EKey::F21), + Key::F22 => Some(EKey::F22), + Key::F23 => Some(EKey::F23), + Key::F24 => Some(EKey::F24), + Key::F25 => Some(EKey::F25), + Key::F26 => Some(EKey::F26), + Key::F27 => Some(EKey::F27), + Key::F28 => Some(EKey::F28), + Key::F29 => Some(EKey::F29), + Key::F30 => Some(EKey::F30), + Key::F31 => Some(EKey::F31), + Key::F32 => Some(EKey::F32), + Key::F33 => Some(EKey::F33), + Key::F34 => Some(EKey::F34), + Key::F35 => Some(EKey::F35), + _ => None, + } +} + +pub fn pointer_button_to_egui(value: MouseButton) -> Option { + match value { + MouseButton::Left => Some(egui::PointerButton::Primary), + MouseButton::Right => Some(egui::PointerButton::Secondary), + MouseButton::Middle => Some(egui::PointerButton::Middle), + MouseButton::Back => Some(egui::PointerButton::Extra1), + MouseButton::Forward => Some(egui::PointerButton::Extra2), + MouseButton::Other(_) => None, + } +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs new file mode 100644 index 00000000000..f4f00ad652f --- /dev/null +++ b/crates/egui_kittest/src/lib.rs @@ -0,0 +1,186 @@ +#![doc = include_str!("../README.md")] +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] + +mod builder; +mod event; +#[cfg(feature = "snapshot")] +mod snapshot; + +#[cfg(feature = "snapshot")] +pub use snapshot::*; +use std::fmt::{Debug, Formatter}; +#[cfg(feature = "wgpu")] +mod texture_to_image; +#[cfg(feature = "wgpu")] +pub mod wgpu; + +pub use kittest; +use std::mem; + +use crate::event::EventState; +pub use builder::*; +use egui::{Pos2, Rect, TexturesDelta, Vec2, ViewportId}; +use kittest::{Node, Queryable}; + +/// The test Harness. This contains everything needed to run the test. +/// Create a new Harness using [`Harness::new`] or [`Harness::builder`]. +pub struct Harness<'a> { + pub ctx: egui::Context, + input: egui::RawInput, + kittest: kittest::State, + output: egui::FullOutput, + texture_deltas: Vec, + update_fn: Box, + event_state: EventState, +} + +impl<'a> Debug for Harness<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.kittest.fmt(f) + } +} + +impl<'a> Harness<'a> { + 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); + + // We need to run egui for a single frame so that the AccessKit state can be initialized + // and users can immediately start querying for widgets. + let mut output = ctx.run(input.clone(), &mut app); + + let mut harness = Self { + update_fn: Box::new(app), + ctx, + input, + kittest: kittest::State::new( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ), + texture_deltas: vec![mem::take(&mut output.textures_delta)], + output, + event_state: EventState::default(), + }; + // Run the harness until it is stable, ensuring that all Areas are shown and animations are done + harness.run(); + harness + } + + pub fn builder() -> HarnessBuilder { + HarnessBuilder::default() + } + + /// Create a new Harness with the given app closure. + /// + /// The ui closure will immediately be called once to create the initial ui. + /// + /// If you e.g. want to customize the size of the window, you can use [`Harness::builder`]. + /// + /// # Example + /// ```rust + /// # use egui::CentralPanel; + /// # use egui_kittest::Harness; + /// let mut harness = Harness::new(|ctx| { + /// CentralPanel::default().show(ctx, |ui| { + /// ui.label("Hello, world!"); + /// }); + /// }); + /// ``` + 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 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 set_dpi(&mut self, dpi: f32) -> &mut Self { + self.ctx.set_pixels_per_point(dpi); + self + } + + /// Run a frame. + /// This will call the app closure with the current context and update the Harness. + pub fn step(&mut self) { + for event in self.kittest.take_events() { + if let Some(event) = self.event_state.kittest_event_to_egui(event) { + self.input.events.push(event); + } + } + + let mut output = self.ctx.run(self.input.take(), self.update_fn.as_mut()); + self.kittest.update( + output + .platform_output + .accesskit_update + .take() + .expect("AccessKit was disabled"), + ); + self.texture_deltas + .push(mem::take(&mut output.textures_delta)); + self.output = output; + } + + /// Run a few frames. + /// This will soon be changed to run the app until it is "stable", meaning + /// - all animations are done + /// - no more repaints are requested + pub fn run(&mut self) { + const STEPS: usize = 2; + for _ in 0..STEPS { + self.step(); + } + } + + /// Access the [`egui::RawInput`] for the next frame. + pub fn input(&self) -> &egui::RawInput { + &self.input + } + + /// Access the [`egui::RawInput`] for the next frame mutably. + pub fn input_mut(&mut self) -> &mut egui::RawInput { + &mut self.input + } + + /// Access the [`egui::FullOutput`] for the last frame. + pub fn output(&self) -> &egui::FullOutput { + &self.output + } + + /// Access the [`kittest::State`]. + pub fn kittest_state(&self) -> &kittest::State { + &self.kittest + } +} + +impl<'t, 'n, 'h> Queryable<'t, 'n> for Harness<'h> +where + 'n: 't, +{ + fn node(&'n self) -> Node<'t> { + self.kittest_state().node() + } +} diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs new file mode 100644 index 00000000000..a2c31a44130 --- /dev/null +++ b/crates/egui_kittest/src/snapshot.rs @@ -0,0 +1,214 @@ +use crate::Harness; +use image::ImageError; +use std::fmt::Display; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum SnapshotError { + /// Image did not match snapshot + Diff { + /// Count of pixels that were different + diff: i32, + + /// Path where the diff image was saved + diff_path: PathBuf, + }, + + /// Error opening the existing snapshot (it probably doesn't exist, check the + /// [`ImageError`] for more information) + OpenSnapshot { + /// Path where the snapshot was expected to be + path: PathBuf, + + /// The error that occurred + err: ImageError, + }, + + /// The size of the image did not match the snapshot + SizeMismatch { + /// Expected size + expected: (u32, u32), + + /// Actual size + actual: (u32, u32), + }, + + /// Error writing the snapshot output + WriteSnapshot { + /// Path where a file was expected to be written + path: PathBuf, + + /// The error that occurred + err: ImageError, + }, +} + +impl Display for SnapshotError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Diff { diff, diff_path } => { + write!( + f, + "Image did not match snapshot. Diff: {diff}, {diff_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:?}\nAt: {path:?}") + } + }, + err => { + write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}") + } + }, + Self::SizeMismatch { expected, actual } => { + write!( + f, + "Image size did not match snapshot. Expected: {expected:?}, Actual: {actual:?}" + ) + } + Self::WriteSnapshot { path, err } => { + write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}") + } + } + } +} + +/// Image snapshot test. +/// +/// # Errors +/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error +/// reading or writing the snapshot. +pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), SnapshotError> { + let snapshots_path = Path::new("tests/snapshots"); + + let path = snapshots_path.join(format!("{name}.png")); + std::fs::create_dir_all(path.parent().expect("Could not get snapshot folder")).ok(); + + let diff_path = snapshots_path.join(format!("{name}.diff.png")); + let current_path = snapshots_path.join(format!("{name}.new.png")); + + current + .save(¤t_path) + .map_err(|err| SnapshotError::WriteSnapshot { + err, + path: current_path, + })?; + + let previous = match image::open(&path) { + Ok(image) => image.to_rgba8(), + Err(err) => { + 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(), + }); + } + + // Looking at dify's source code, the threshold is based on the distance between two colors in + // YIQ color space. + // The default is 0.1, but we'll try 0.0 because ideally the output should not change at all. + // We might have to increase the threshold if there are minor differences when running tests + // on different gpus or different backends. + let threshold = 0.0; + let result = dify::diff::get_results( + previous, + current.clone(), + threshold, + true, + None, + &None, + &None, + ); + + if let Some((diff, result_image)) = result { + result_image + .save(diff_path.clone()) + .map_err(|err| SnapshotError::WriteSnapshot { + path: diff_path.clone(), + err, + })?; + 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(); + } + + Ok(()) +} + +fn should_update_snapshots() -> bool { + std::env::var("UPDATE_SNAPSHOTS").is_ok() +} + +fn maybe_update_snapshot( + snapshot_path: &Path, + current: &image::RgbaImage, +) -> Result<(), SnapshotError> { + if should_update_snapshots() { + current + .save(snapshot_path) + .map_err(|err| SnapshotError::WriteSnapshot { + err, + path: snapshot_path.into(), + })?; + println!("Updated snapshot: {snapshot_path:?}"); + } + Ok(()) +} + +/// Image snapshot test. +/// +/// # Panics +/// Panics if the image does not match the snapshot or if there was an error reading or writing the +/// snapshot. +#[track_caller] +pub fn image_snapshot(current: &image::RgbaImage, name: &str) { + match try_image_snapshot(current, name) { + Ok(_) => {} + Err(err) => { + panic!("{}", err); + } + } +} + +#[cfg(feature = "wgpu")] +impl Harness<'_> { + /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// + /// # Errors + /// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error + /// reading or writing the snapshot. + #[track_caller] + pub fn try_wgpu_snapshot(&self, name: &str) -> Result<(), SnapshotError> { + let image = crate::wgpu::TestRenderer::new().render(self); + try_image_snapshot(&image, name) + } + + /// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot. + /// + /// # Panics + /// Panics if the image does not match the snapshot or if there was an error reading or writing the + /// snapshot. + #[track_caller] + pub fn wgpu_snapshot(&self, name: &str) { + match self.try_wgpu_snapshot(name) { + Ok(_) => {} + Err(err) => { + panic!("{}", err); + } + } + } +} diff --git a/crates/egui_kittest/src/texture_to_image.rs b/crates/egui_kittest/src/texture_to_image.rs new file mode 100644 index 00000000000..98803ac8a37 --- /dev/null +++ b/crates/egui_kittest/src/texture_to_image.rs @@ -0,0 +1,83 @@ +use egui_wgpu::wgpu; +use egui_wgpu::wgpu::{Device, Extent3d, Queue, Texture}; +use image::RgbaImage; +use std::iter; +use std::mem::size_of; +use std::sync::mpsc::channel; + +pub(crate) fn texture_to_image(device: &Device, queue: &Queue, texture: &Texture) -> RgbaImage { + let buffer_dimensions = + BufferDimensions::new(texture.width() as usize, texture.height() as usize); + + let output_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Texture to bytes output buffer"), + size: (buffer_dimensions.padded_bytes_per_row * buffer_dimensions.height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Texture to bytes encoder"), + }); + + // Copy the data from the texture to the buffer + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::ImageCopyBuffer { + buffer: &output_buffer, + layout: wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(buffer_dimensions.padded_bytes_per_row as u32), + rows_per_image: None, + }, + }, + Extent3d { + width: texture.width(), + height: texture.height(), + depth_or_array_layers: 1, + }, + ); + + let submission_index = queue.submit(iter::once(encoder.finish())); + + // Note that we're not calling `.await` here. + let buffer_slice = output_buffer.slice(..); + // Sets the buffer up for mapping, sending over the result of the mapping back to us when it is finished. + let (sender, receiver) = channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |v| drop(sender.send(v))); + + // Poll the device in a blocking manner so that our future resolves. + device.poll(wgpu::Maintain::WaitForSubmissionIndex(submission_index)); + + receiver.recv().unwrap().unwrap(); + let buffer_slice = output_buffer.slice(..); + let data = buffer_slice.get_mapped_range(); + let data = data + .chunks_exact(buffer_dimensions.padded_bytes_per_row) + .flat_map(|row| row.iter().take(buffer_dimensions.unpadded_bytes_per_row)) + .copied() + .collect::>(); + + RgbaImage::from_raw(texture.width(), texture.height(), data).expect("Failed to create image") +} + +struct BufferDimensions { + height: usize, + unpadded_bytes_per_row: usize, + padded_bytes_per_row: usize, +} + +impl BufferDimensions { + fn new(width: usize, height: usize) -> Self { + let bytes_per_pixel = size_of::(); + let unpadded_bytes_per_row = width * bytes_per_pixel; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded_bytes_per_row_padding = (align - unpadded_bytes_per_row % align) % align; + let padded_bytes_per_row = unpadded_bytes_per_row + padded_bytes_per_row_padding; + Self { + height, + unpadded_bytes_per_row, + padded_bytes_per_row, + } + } +} diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs new file mode 100644 index 00000000000..c79f411433a --- /dev/null +++ b/crates/egui_kittest/src/wgpu.rs @@ -0,0 +1,141 @@ +use crate::texture_to_image::texture_to_image; +use crate::Harness; +use egui_wgpu::wgpu::{Backends, InstanceDescriptor, StoreOp, TextureFormat}; +use egui_wgpu::{wgpu, ScreenDescriptor}; +use image::RgbaImage; +use std::iter::once; +use wgpu::Maintain; + +pub struct TestRenderer { + device: wgpu::Device, + queue: wgpu::Queue, + dithering: bool, +} + +impl Default for TestRenderer { + fn default() -> Self { + Self::new() + } +} + +impl TestRenderer { + pub fn new() -> Self { + let instance = wgpu::Instance::new(InstanceDescriptor::default()); + + let adapters = instance.enumerate_adapters(Backends::all()); + let adapter = adapters.first().expect("No adapter found"); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("Egui Device"), + memory_hints: Default::default(), + required_limits: Default::default(), + required_features: Default::default(), + }, + None, + )) + .expect("Failed to create device"); + + Self::create(device, queue) + } + + pub fn create(device: wgpu::Device, queue: wgpu::Queue) -> Self { + Self { + device, + queue, + dithering: false, + } + } + + #[inline] + pub fn with_dithering(mut self, dithering: bool) -> Self { + self.dithering = dithering; + self + } + + pub fn render(&mut self, harness: &Harness<'_>) -> RgbaImage { + let mut renderer = egui_wgpu::Renderer::new( + &self.device, + TextureFormat::Rgba8Unorm, + None, + 1, + self.dithering, + ); + + for delta in &harness.texture_deltas { + for (id, image_delta) in &delta.set { + renderer.update_texture(&self.device, &self.queue, *id, image_delta); + } + } + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Egui Command Encoder"), + }); + + let size = harness.ctx.screen_rect().size() * harness.ctx.pixels_per_point(); + let screen = ScreenDescriptor { + pixels_per_point: harness.ctx.pixels_per_point(), + size_in_pixels: [size.x.round() as u32, size.y.round() as u32], + }; + + let tessellated = harness.ctx.tessellate( + harness.output().shapes.clone(), + harness.ctx.pixels_per_point(), + ); + + let user_buffers = renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, + &tessellated, + &screen, + ); + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Egui Texture"), + size: wgpu::Extent3d { + width: screen.size_in_pixels[0], + height: screen.size_in_pixels[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Egui Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &texture_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }) + .forget_lifetime(); + + renderer.render(&mut pass, &tessellated, &screen); + } + + self.queue + .submit(user_buffers.into_iter().chain(once(encoder.finish()))); + + self.device.poll(Maintain::Wait); + + texture_to_image(&self.device, &self.queue, &texture) + } +} 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..66b21e7f4bf --- /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:36c1b432140456ea5cbb687076b1c910aea8b31affd33a0ece22218f60af2d6e +size 2296 diff --git a/deny.toml b/deny.toml index f5291700dfc..5c946782b35 100644 --- a/deny.toml +++ b/deny.toml @@ -60,7 +60,6 @@ skip = [ { name = "windows-core" }, # old version via accesskit_windows { name = "windows" }, # old version via accesskit_windows { name = "glow" }, # wgpu uses an old `glow`, but realistically no one uses _both_ `egui_wgpu` and `egui_glow`, so we won't get a duplicate dependency - ] skip-tree = [ { name = "criterion" }, # dev-dependency @@ -109,3 +108,7 @@ license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] [sources] unknown-registry = "deny" unknown-git = "deny" + +allow-git = [ + "https://github.com/rerun-io/kittest", # TODO(lucasmerlin): remove this once the kittest crate is published" +]