Add egui testing library (#5166)
- closes #3491 - closes #3926 This adds a testing library to egui based on [kittest](https://github.com/rerun-io/kittest). Kittest is a new [AccessKit](https://github.com/AccessKit/accesskit/)-based testing library. The api is inspired by the js [testing-library](https://testing-library.com/) where the idea is also to query the dom based on accessibility attributes. We made kittest with egui in mind but it should work with any rust gui framework with AccessKit support. It currently has support for: - running the egui app, frame by frame - building the AccessKit tree - ergonomic queries via kittest - via e.g. get_by_name, get_by_role - simulating events based on the accesskit node id - creating arbitrary events based on Harness::input_mut - rendering screenshots via wgpu - snapshot tests with these screenshots A simple test looks like this: ```rust 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"); } ``` ~Since getting wgpu to run in ci is a hassle, I'm taking another shot at creating a software renderer for egui (ideally without a huge dependency like skia)~ (this didn't work as well as I hoped and it turns out in CI you can just run tests on a mac runner which comes with a real GPU) Here is a example of a failed snapshot test in ci, it will say which snapshot failed and upload an artifact with the before / after and diff images: https://github.com/emilk/egui/actions/runs/11183049487/job/31090724606?pr=5166
This commit is contained in:
parent
707cd03357
commit
70a01138b7
|
|
@ -1,2 +1,3 @@
|
|||
* text=auto eol=lf
|
||||
Cargo.lock linguist-generated=false
|
||||
**/tests/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
**/target
|
||||
**/target_ra
|
||||
**/target_wasm
|
||||
**/tests/snapshots/**/*.diff.png
|
||||
**/tests/snapshots/**/*.new.png
|
||||
/.*.json
|
||||
/.vscode
|
||||
/media/*
|
||||
|
|
|
|||
77
Cargo.lock
77
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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:#?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a725aa81433f301fda4ff8a28be869366332964995d1ae4ed996591596eb7e2
|
||||
size 31461
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:36028d85f49ee77562250214237def2b676ecc9ed413d2fd8afc473d61289ca1
|
||||
size 32761
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8d4f004ee11ea68ae0f30657601b6e51403fcc3ca91fa5b8cdcb58585d8d40d
|
||||
size 78318
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:684648bea4ef5ce138fc25dbe7576e3937a797e87f2244cb3656ff8b4c2777f5
|
||||
size 11574
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ad38bff7cc5661be43e730e1b34c444b571b24b9f50791209496a1687610dd3d
|
||||
size 20543
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ff78748f2571c49638d8fe8fdc859aaa5181758aad65498b7217551350fb9138
|
||||
size 20672
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9dee66004cc47f5e27aaac34d137ff005eedf70cbfa3fbe43153dfd5c09d5e18
|
||||
size 10610
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0d1086b789f1fe0a8085c86f5b6a5ae7ecb53020f385b84775d6812ebc9d74a3
|
||||
size 132349
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:08be378c01e376aab6e99ba3158519bbd7b301e815dc3447b57c9abab558977f
|
||||
size 24237
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:53097b2c26ebcba8b8ad657ed8e52ca40261155e96dbbfca1e8eb01fce25d290
|
||||
size 17586
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9c8395e6b4287b92d85a52ca2d47750f67abeb0ad88c6b42264bfe2e62fd09d
|
||||
size 22283
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:38d21b6f8c364f86ad759e88ea1068649c23c58ded5d2953ba8ff1c83b46112f
|
||||
size 63884
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83162f8c496a55230375dbc4cc636cfacf63049c913904bea9d06bdb56e63da6
|
||||
size 36282
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2537c681d1ffceb5cf4bf19d11295891525c96aea0b1422ab28f133021185be0
|
||||
size 17451
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79ce1dbf7627579d4e10de6494e34d8fd9685506d7b35cb3c9148f90f8c01366
|
||||
size 25144
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5068df8549ffc91028addfec6f851f12a4de80e208b50b39e4d44b6aa2c7240e
|
||||
size 261946
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be2ac005fd5aafa293e21b162c22a09078e46d2d45b6208ce0f7841eeb05314a
|
||||
size 183934
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2e3436906f7ac459b7f4330a286937722e78ad885ae1e90f75be566e970a8ca7
|
||||
size 116899
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:df7dabf726620ab5205ce153f692d1ba02365848ead7b79c95b873d5121d52a6
|
||||
size 25850
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae6c2e3aad43cfad3322340ff7045ec50ba01d58feb7b8acc5ba062a8a5c9ab8
|
||||
size 70230
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ec0c2efff75cb8d621f5a4ea59f9fa8d3076521ca34f4499e07fb9dc8681d7ba
|
||||
size 65916
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c04aee0a3a77a3691bb601a93871117500be917e0896138fda43251454ec04c2
|
||||
size 20988
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:814d863deaa4fa029044da1783db87744f0d82e874edd6cbab16e712ed8715aa
|
||||
size 59881
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e682f5cb9ecb1bdf89281c2ba1612078e70e97f28c76facc64d717e4015ced6a
|
||||
size 12977
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:15acfb041cc53ef9bd966d6edd53a6b692cdb645ae5cf34bc20e70d403371c30
|
||||
size 34809
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5dc632962f8894c4f20a48c9b9e57d60470f3f83ef7f19d05854dba718610a2f
|
||||
size 161820
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
[package]
|
||||
name = "egui_kittest"
|
||||
version.workspace = true
|
||||
authors = ["Lucas Meurer <lucasmeurer96@gmail.com>", "Emil Ernerfeldt <emil.ernerfeldt@gmail.com>"]
|
||||
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
|
||||
|
|
@ -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");
|
||||
}
|
||||
```
|
||||
|
|
@ -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<Vec2>) -> 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<egui::Event> {
|
||||
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<egui::Key> {
|
||||
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<egui::PointerButton> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TexturesDelta>,
|
||||
update_fn: Box<dyn FnMut(&egui::Context) + 'a>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
|
||||
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::<u32>();
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:36c1b432140456ea5cbb687076b1c910aea8b31affd33a0ece22218f60af2d6e
|
||||
size 2296
|
||||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue