mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Add kittest::Plugin
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use crate::app_kind::AppKind;
|
||||
#[cfg(feature = "eframe")]
|
||||
use crate::app_kind::AppKindEframe;
|
||||
use crate::{Harness, LazyRenderer, TestRenderer};
|
||||
use crate::{Harness, LazyRenderer, Plugin, TestRenderer};
|
||||
use egui::{Pos2, Rect, Vec2};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct HarnessBuilder<State = ()> {
|
||||
pub(crate) state: PhantomData<State>,
|
||||
pub(crate) renderer: Box<dyn TestRenderer>,
|
||||
pub(crate) wait_for_pending_images: bool,
|
||||
pub(crate) plugins: Vec<Box<dyn Plugin<State>>>,
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
pub(crate) default_snapshot_options: crate::SnapshotOptions,
|
||||
@@ -37,6 +38,7 @@ impl<State> Default for HarnessBuilder<State> {
|
||||
step_dt: 1.0 / 4.0,
|
||||
wait_for_pending_images: true,
|
||||
os: egui::os::OperatingSystem::Nix,
|
||||
plugins: Vec::new(),
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
default_snapshot_options: crate::SnapshotOptions::default(),
|
||||
@@ -47,7 +49,7 @@ impl<State> Default for HarnessBuilder<State> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<State> HarnessBuilder<State> {
|
||||
impl<State: 'static> HarnessBuilder<State> {
|
||||
/// Set the size of the window.
|
||||
#[inline]
|
||||
pub fn with_size(mut self, size: impl Into<Vec2>) -> Self {
|
||||
@@ -161,6 +163,16 @@ impl<State> HarnessBuilder<State> {
|
||||
self.renderer(crate::wgpu::WgpuTestRenderer::from_setup(setup))
|
||||
}
|
||||
|
||||
/// Register a [`Plugin`] on this harness.
|
||||
///
|
||||
/// Plugins observe the harness lifecycle (`before_step`, `after_step`, `on_snapshot`,
|
||||
/// `on_test_result`, etc.) and are dispatched in registration order.
|
||||
#[inline]
|
||||
pub fn with_plugin(mut self, plugin: impl Plugin<State>) -> Self {
|
||||
self.plugins.push(Box::new(plugin));
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new Harness with the given ui closure and a state.
|
||||
///
|
||||
/// The ui closure will immediately be called once to create the initial ui.
|
||||
|
||||
@@ -14,12 +14,15 @@ pub use crate::snapshot::*;
|
||||
mod app_kind;
|
||||
mod config;
|
||||
mod node;
|
||||
mod plugin;
|
||||
mod renderer;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod texture_to_image;
|
||||
#[cfg(feature = "wgpu")]
|
||||
pub mod wgpu;
|
||||
|
||||
pub use crate::plugin::{PanicLocation, Plugin, TestResult, install_panic_hook};
|
||||
|
||||
// re-exports:
|
||||
pub use {
|
||||
self::{builder::*, node::*, renderer::*},
|
||||
@@ -69,7 +72,7 @@ impl Display for ExceededMaxStepsError {
|
||||
/// Some egui style options are changed from the defaults:
|
||||
/// - The cursor blinking is disabled
|
||||
/// - The scroll animation is disabled
|
||||
pub struct Harness<'a, State = ()> {
|
||||
pub struct Harness<'a, State: 'static = ()> {
|
||||
pub ctx: egui::Context,
|
||||
input: egui::RawInput,
|
||||
kittest: kittest::State,
|
||||
@@ -83,10 +86,17 @@ pub struct Harness<'a, State = ()> {
|
||||
wait_for_pending_images: bool,
|
||||
queued_events: EventQueue,
|
||||
|
||||
plugins: Vec<Box<dyn Plugin<State>>>,
|
||||
pending_plugins: Vec<Box<dyn Plugin<State>>>,
|
||||
in_dispatch: bool,
|
||||
entry_location: Option<&'static std::panic::Location<'static>>,
|
||||
consumed_event_locations: Vec<&'static std::panic::Location<'static>>,
|
||||
last_accesskit_update: Option<egui::accesskit::TreeUpdate>,
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
default_snapshot_options: SnapshotOptions,
|
||||
#[cfg(feature = "snapshot")]
|
||||
snapshot_results: SnapshotResults,
|
||||
snapshot_results: Option<SnapshotResults>,
|
||||
}
|
||||
|
||||
impl<State> Debug for Harness<'_, State> {
|
||||
@@ -95,7 +105,7 @@ impl<State> Debug for Harness<'_, State> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, State> Harness<'a, State> {
|
||||
impl<'a, State: 'static> Harness<'a, State> {
|
||||
#[track_caller]
|
||||
pub(crate) fn from_builder(
|
||||
builder: HarnessBuilder<State>,
|
||||
@@ -113,6 +123,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
state: _,
|
||||
mut renderer,
|
||||
wait_for_pending_images,
|
||||
plugins,
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
default_snapshot_options,
|
||||
@@ -149,17 +160,17 @@ impl<'a, State> Harness<'a, State> {
|
||||
|
||||
renderer.handle_delta(&output.textures_delta);
|
||||
|
||||
let initial_accesskit = output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.take()
|
||||
.expect("AccessKit was disabled");
|
||||
|
||||
let mut harness = Self {
|
||||
app,
|
||||
ctx,
|
||||
input,
|
||||
kittest: kittest::State::new(
|
||||
output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.take()
|
||||
.expect("AccessKit was disabled"),
|
||||
),
|
||||
kittest: kittest::State::new(initial_accesskit.clone()),
|
||||
output,
|
||||
response,
|
||||
state,
|
||||
@@ -169,11 +180,18 @@ impl<'a, State> Harness<'a, State> {
|
||||
wait_for_pending_images,
|
||||
queued_events: Default::default(),
|
||||
|
||||
plugins,
|
||||
pending_plugins: Vec::new(),
|
||||
in_dispatch: false,
|
||||
entry_location: None,
|
||||
consumed_event_locations: Vec::new(),
|
||||
last_accesskit_update: Some(initial_accesskit),
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
default_snapshot_options,
|
||||
|
||||
#[cfg(feature = "snapshot")]
|
||||
snapshot_results: SnapshotResults::default(),
|
||||
snapshot_results: Some(SnapshotResults::default()),
|
||||
};
|
||||
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
|
||||
harness.run_ok();
|
||||
@@ -185,6 +203,89 @@ impl<'a, State> Harness<'a, State> {
|
||||
HarnessBuilder::default()
|
||||
}
|
||||
|
||||
/// Register a [`Plugin`] after construction.
|
||||
///
|
||||
/// See [`HarnessBuilder::with_plugin`] to register before the first frame runs.
|
||||
pub fn add_plugin(&mut self, plugin: impl Plugin<State>) {
|
||||
let boxed: Box<dyn Plugin<State>> = Box::new(plugin);
|
||||
if self.in_dispatch {
|
||||
self.pending_plugins.push(boxed);
|
||||
} else {
|
||||
self.plugins.push(boxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow a registered plugin by type. Returns the first plugin of the matching type
|
||||
/// in registration order, or `None` if no plugin of that type is registered.
|
||||
pub fn plugin<P: Plugin<State>>(&self) -> Option<&P> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.find_map(|p| p.as_any().downcast_ref::<P>())
|
||||
}
|
||||
|
||||
/// Mutably borrow a registered plugin by type.
|
||||
pub fn plugin_mut<P: Plugin<State>>(&mut self) -> Option<&mut P> {
|
||||
self.plugins
|
||||
.iter_mut()
|
||||
.find_map(|p| p.as_any_mut().downcast_mut::<P>())
|
||||
}
|
||||
|
||||
/// Remove and return the first plugin of the given type.
|
||||
pub fn take_plugin<P: Plugin<State>>(&mut self) -> Option<Box<P>> {
|
||||
let idx = self.plugins.iter().position(|p| p.as_any().is::<P>())?;
|
||||
let boxed = self.plugins.remove(idx);
|
||||
let raw: *mut dyn Plugin<State> = Box::into_raw(boxed);
|
||||
// SAFETY: `is::<P>()` confirmed the concrete type is `P`. Fat-to-thin pointer
|
||||
// cast preserves the data pointer, which is the address of the underlying `P`.
|
||||
#[expect(unsafe_code)]
|
||||
Some(unsafe { Box::from_raw(raw.cast::<P>()) })
|
||||
}
|
||||
|
||||
/// Advance the harness by one frame without firing plugin hooks.
|
||||
///
|
||||
/// Use this from inside a plugin hook when the plugin needs to drive additional
|
||||
/// frames — e.g. an inspector plugin that blocks on user input and re-renders
|
||||
/// after each injected event. Calling [`Self::step`] or [`Self::run`] from inside
|
||||
/// a hook would recurse infinitely through that plugin's own `after_step`.
|
||||
pub fn advance_frame(&mut self) {
|
||||
self._step_inner(false);
|
||||
}
|
||||
|
||||
/// The most recent AccessKit tree update, if any. Useful for plugins that mirror
|
||||
/// the accessibility tree to an external debugger.
|
||||
pub fn accesskit_tree_update(&self) -> Option<&egui::accesskit::TreeUpdate> {
|
||||
self.last_accesskit_update.as_ref()
|
||||
}
|
||||
|
||||
/// [`std::panic::Location`] of the most recent public `#[track_caller]` entry point
|
||||
/// (e.g. the caller of `step()` / `run()`), or `None` if no such call has been made yet.
|
||||
pub fn entry_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
self.entry_location
|
||||
}
|
||||
|
||||
/// Locations of the events consumed during the most recent step, in order.
|
||||
pub fn consumed_event_locations(&self) -> &[&'static std::panic::Location<'static>] {
|
||||
&self.consumed_event_locations
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, mut f: impl FnMut(&mut dyn Plugin<State>, &mut Self)) {
|
||||
if self.plugins.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut plugins = std::mem::take(&mut self.plugins);
|
||||
self.in_dispatch = true;
|
||||
for p in &mut plugins {
|
||||
f(p.as_mut(), self);
|
||||
}
|
||||
self.in_dispatch = false;
|
||||
self.plugins = plugins;
|
||||
// Promote any plugins registered mid-dispatch to the end of the active list.
|
||||
if !self.pending_plugins.is_empty() {
|
||||
let pending = std::mem::take(&mut self.pending_plugins);
|
||||
self.plugins.extend(pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new Harness with the given ui closure and a state.
|
||||
///
|
||||
/// The ui closure will immediately be called once to create the initial ui.
|
||||
@@ -240,17 +341,28 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// Run a frame for each queued event (or a single frame if there are no events).
|
||||
/// This will call the app closure with each queued event and
|
||||
/// update the Harness.
|
||||
#[track_caller]
|
||||
pub fn step(&mut self) {
|
||||
debug_assert!(
|
||||
!self.in_dispatch,
|
||||
"Harness::step called from inside a plugin hook — use Harness::advance_frame instead to avoid infinite recursion"
|
||||
);
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
let events = std::mem::take(&mut *self.queued_events.lock());
|
||||
if events.is_empty() {
|
||||
self.consumed_event_locations.clear();
|
||||
self._step(false);
|
||||
}
|
||||
for event in events {
|
||||
self.consumed_event_locations.clear();
|
||||
match event {
|
||||
EventType::Event(event) => {
|
||||
self.input.events.push(event);
|
||||
EventType::Event(event, loc) => {
|
||||
self.consumed_event_locations.push(loc);
|
||||
self.input.events.push(event.clone());
|
||||
self.dispatch(|p, h| p.on_event(h, &event));
|
||||
}
|
||||
EventType::Modifiers(modifiers) => {
|
||||
EventType::Modifiers(modifiers, loc) => {
|
||||
self.consumed_event_locations.push(loc);
|
||||
self.input.modifiers = modifiers;
|
||||
}
|
||||
}
|
||||
@@ -258,20 +370,28 @@ impl<'a, State> Harness<'a, State> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single step. This will not process any events.
|
||||
/// Run a single step, firing `before_step` / `after_step` plugin hooks.
|
||||
fn _step(&mut self, sizing_pass: bool) {
|
||||
self.dispatch(|p, h| p.before_step(h));
|
||||
self._step_inner(sizing_pass);
|
||||
self.dispatch(|p, h| p.after_step(h));
|
||||
}
|
||||
|
||||
/// Core frame advance. Does NOT fire plugin hooks — callable from within
|
||||
/// hooks via [`Self::advance_frame`] without recursing.
|
||||
fn _step_inner(&mut self, sizing_pass: bool) {
|
||||
self.input.predicted_dt = self.step_dt;
|
||||
|
||||
let mut output = self.ctx.run_ui(self.input.take(), |ui| {
|
||||
self.response = self.app.run(ui, &mut self.state, sizing_pass);
|
||||
});
|
||||
self.kittest.update(
|
||||
output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.take()
|
||||
.expect("AccessKit was disabled"),
|
||||
);
|
||||
let accesskit_update = output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.take()
|
||||
.expect("AccessKit was disabled");
|
||||
self.last_accesskit_update = Some(accesskit_update.clone());
|
||||
self.kittest.update(accesskit_update);
|
||||
self.renderer.handle_delta(&output.textures_delta);
|
||||
self.output = output;
|
||||
}
|
||||
@@ -301,7 +421,9 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// Resize the test harness to fit the contents. This only works when creating the Harness via
|
||||
/// [`Harness::new_ui`] / [`Harness::new_ui_state`] or
|
||||
/// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`].
|
||||
#[track_caller]
|
||||
pub fn fit_contents(&mut self) {
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
self._step(true);
|
||||
|
||||
// Calculate size including all content (main UI + popups + tooltips)
|
||||
@@ -330,7 +452,8 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - [`Harness::run_steps`].
|
||||
#[track_caller]
|
||||
pub fn run(&mut self) -> u64 {
|
||||
match self.try_run() {
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
match self._try_run(false) {
|
||||
Ok(steps) => steps,
|
||||
Err(err) => {
|
||||
panic!("{err}");
|
||||
@@ -339,8 +462,14 @@ impl<'a, State> Harness<'a, State> {
|
||||
}
|
||||
|
||||
fn _try_run(&mut self, sleep: bool) -> Result<u64, ExceededMaxStepsError> {
|
||||
debug_assert!(
|
||||
!self.in_dispatch,
|
||||
"Harness::run / Harness::try_run called from inside a plugin hook — use Harness::advance_frame instead to avoid infinite recursion"
|
||||
);
|
||||
self.dispatch(|p, h| p.before_run(h));
|
||||
|
||||
let mut steps = 0;
|
||||
loop {
|
||||
let result = loop {
|
||||
steps += 1;
|
||||
self.step();
|
||||
|
||||
@@ -348,18 +477,19 @@ impl<'a, State> Harness<'a, State> {
|
||||
|
||||
// We only care about immediate repaints
|
||||
if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images {
|
||||
break;
|
||||
break Ok(steps);
|
||||
} else if sleep || wait_for_images {
|
||||
std::thread::sleep(Duration::from_secs_f32(self.step_dt));
|
||||
}
|
||||
if steps > self.max_steps {
|
||||
return Err(ExceededMaxStepsError {
|
||||
break Err(ExceededMaxStepsError {
|
||||
max_steps: self.max_steps,
|
||||
repaint_causes: self.ctx.repaint_causes(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(steps)
|
||||
};
|
||||
self.dispatch(|p, h| p.after_run(h, result.as_ref().map(|s| *s)));
|
||||
result
|
||||
}
|
||||
|
||||
/// Run until
|
||||
@@ -378,7 +508,9 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - [`Harness::step`].
|
||||
/// - [`Harness::run_steps`].
|
||||
/// - [`Harness::try_run_realtime`].
|
||||
#[track_caller]
|
||||
pub fn try_run(&mut self) -> Result<u64, ExceededMaxStepsError> {
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
self._try_run(false)
|
||||
}
|
||||
|
||||
@@ -395,8 +527,10 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - [`Harness::step`].
|
||||
/// - [`Harness::run_steps`].
|
||||
/// - [`Harness::try_run_realtime`].
|
||||
#[track_caller]
|
||||
pub fn run_ok(&mut self) -> Option<u64> {
|
||||
self.try_run().ok()
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
self._try_run(false).ok()
|
||||
}
|
||||
|
||||
/// Run multiple frames, sleeping for [`HarnessBuilder::with_step_dt`] between frames.
|
||||
@@ -418,13 +552,17 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - [`Harness::step`].
|
||||
/// - [`Harness::run_steps`].
|
||||
/// - [`Harness::try_run`].
|
||||
#[track_caller]
|
||||
pub fn try_run_realtime(&mut self) -> Result<u64, ExceededMaxStepsError> {
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
self._try_run(true)
|
||||
}
|
||||
|
||||
/// Run a number of steps.
|
||||
/// Equivalent to calling [`Harness::step`] x times.
|
||||
#[track_caller]
|
||||
pub fn run_steps(&mut self, steps: usize) {
|
||||
self.entry_location = Some(std::panic::Location::caller());
|
||||
for _ in 0..steps {
|
||||
self.step();
|
||||
}
|
||||
@@ -460,32 +598,35 @@ impl<'a, State> Harness<'a, State> {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
/// Consume the harness and return the state.
|
||||
pub fn into_state(self) -> State {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Queue an event to be processed in the next frame.
|
||||
#[track_caller]
|
||||
pub fn event(&self, event: egui::Event) {
|
||||
self.queued_events.lock().push(EventType::Event(event));
|
||||
self.queued_events
|
||||
.lock()
|
||||
.push(EventType::Event(event, std::panic::Location::caller()));
|
||||
}
|
||||
|
||||
/// Queue an event with modifiers.
|
||||
///
|
||||
/// Queues the modifiers to be pressed, then the event, then the modifiers to be released.
|
||||
#[track_caller]
|
||||
pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) {
|
||||
let caller = std::panic::Location::caller();
|
||||
let mut queue = self.queued_events.lock();
|
||||
queue.push(EventType::Modifiers(modifiers));
|
||||
queue.push(EventType::Event(event));
|
||||
queue.push(EventType::Modifiers(Modifiers::default()));
|
||||
queue.push(EventType::Modifiers(modifiers, caller));
|
||||
queue.push(EventType::Event(event, caller));
|
||||
queue.push(EventType::Modifiers(Modifiers::default(), caller));
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn modifiers(&self, modifiers: Modifiers) {
|
||||
self.queued_events
|
||||
.lock()
|
||||
.push(EventType::Modifiers(modifiers));
|
||||
self.queued_events.lock().push(EventType::Modifiers(
|
||||
modifiers,
|
||||
std::panic::Location::caller(),
|
||||
));
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn key_down(&self, key: egui::Key) {
|
||||
self.event(egui::Event::Key {
|
||||
key,
|
||||
@@ -496,6 +637,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
||||
self.event_modifiers(
|
||||
egui::Event::Key {
|
||||
@@ -509,6 +651,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn key_up(&self, key: egui::Key) {
|
||||
self.event(egui::Event::Key {
|
||||
key,
|
||||
@@ -519,6 +662,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
||||
self.event_modifiers(
|
||||
egui::Event::Key {
|
||||
@@ -539,6 +683,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - Press [`Key::B`]
|
||||
/// - Release [`Key::B`]
|
||||
/// - Release [`Key::A`]
|
||||
#[track_caller]
|
||||
pub fn key_combination(&self, keys: &[Key]) {
|
||||
for key in keys {
|
||||
self.key_down(*key);
|
||||
@@ -557,6 +702,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - Release [`Key::B`]
|
||||
/// - Release [`Key::A`]
|
||||
/// - Release [`Modifiers::COMMAND`]
|
||||
#[track_caller]
|
||||
pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) {
|
||||
self.modifiers(modifiers);
|
||||
|
||||
@@ -578,6 +724,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// Press a key.
|
||||
///
|
||||
/// This will create a key down event and a key up event.
|
||||
#[track_caller]
|
||||
pub fn key_press(&self, key: egui::Key) {
|
||||
self.key_combination(&[key]);
|
||||
}
|
||||
@@ -589,16 +736,19 @@ impl<'a, State> Harness<'a, State> {
|
||||
/// - create a key down event
|
||||
/// - create a key up event
|
||||
/// - reset the modifiers
|
||||
#[track_caller]
|
||||
pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
|
||||
self.key_combination_modifiers(modifiers, &[key]);
|
||||
}
|
||||
|
||||
/// Move mouse cursor to this position.
|
||||
#[track_caller]
|
||||
pub fn hover_at(&self, pos: egui::Pos2) {
|
||||
self.event(egui::Event::PointerMoved(pos));
|
||||
}
|
||||
|
||||
/// Start dragging from a position.
|
||||
#[track_caller]
|
||||
pub fn drag_at(&self, pos: egui::Pos2) {
|
||||
self.event(egui::Event::PointerButton {
|
||||
pos,
|
||||
@@ -609,6 +759,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
}
|
||||
|
||||
/// Stop dragging and remove cursor.
|
||||
#[track_caller]
|
||||
pub fn drop_at(&self, pos: egui::Pos2) {
|
||||
self.event(egui::Event::PointerButton {
|
||||
pos,
|
||||
@@ -625,6 +776,7 @@ impl<'a, State> Harness<'a, State> {
|
||||
///
|
||||
/// If you click a button and then take a snapshot, the button will be shown as hovered.
|
||||
/// If you don't want that, you can call this method after clicking.
|
||||
#[track_caller]
|
||||
pub fn remove_cursor(&self) {
|
||||
self.event(egui::Event::PointerGone);
|
||||
}
|
||||
@@ -668,7 +820,9 @@ impl<'a, State> Harness<'a, State> {
|
||||
});
|
||||
}
|
||||
|
||||
self.renderer.render(&self.ctx, &output)
|
||||
let image = self.renderer.render(&self.ctx, &output)?;
|
||||
self.dispatch(|p, h| p.on_render(h, &image));
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Get the root viewport output
|
||||
@@ -748,39 +902,36 @@ impl<'a, State> Harness<'a, State> {
|
||||
);
|
||||
}
|
||||
|
||||
struct UiApp {
|
||||
f: Box<dyn FnMut(&mut egui::Ui)>,
|
||||
// Wrap the whole `Harness` in an `eframe::App` adapter so we don't need to
|
||||
// destructure `self` (which we can't, since `Harness` implements `Drop`).
|
||||
// The adapter delegates `ui`/`logic` through the stored `AppKind`.
|
||||
struct HarnessAsApp<State: 'static> {
|
||||
harness: Harness<'static, State>,
|
||||
}
|
||||
|
||||
impl eframe::App for UiApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
(self.f)(ui);
|
||||
impl<State: 'static> eframe::App for HarnessAsApp<State> {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
if let AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) =
|
||||
&mut self.harness.app
|
||||
{
|
||||
get_app(&mut self.harness.state).logic(ctx, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let harness = &mut self.harness;
|
||||
match &mut harness.app {
|
||||
AppKind::Ui(f) => f(ui),
|
||||
AppKind::UiState(f) => f(ui, &mut harness.state),
|
||||
AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) => {
|
||||
get_app(&mut harness.state).ui(ui, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UiStateApp<State> {
|
||||
f: Box<dyn FnMut(&mut egui::Ui, &mut State)>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl<State: 'static> eframe::App for UiStateApp<State> {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let Self { f, state } = self;
|
||||
f(ui, state);
|
||||
}
|
||||
}
|
||||
|
||||
use crate::app_kind::AppKindEframe;
|
||||
|
||||
let Self {
|
||||
ctx, state, app, ..
|
||||
} = self;
|
||||
|
||||
let eframe_app: Box<dyn eframe::App> = match app {
|
||||
AppKind::Ui(f) => Box::new(UiApp { f }),
|
||||
AppKind::UiState(f) => Box::new(UiStateApp { f, state }),
|
||||
AppKind::Eframe(AppKindEframe { take_app, .. }) => take_app(state),
|
||||
};
|
||||
let ctx = self.ctx.clone();
|
||||
let eframe_app: Box<dyn eframe::App> = Box::new(HarnessAsApp { harness: self });
|
||||
|
||||
eframe::run_native_ext(
|
||||
"egui_kittest",
|
||||
@@ -814,7 +965,7 @@ impl<'a> Harness<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'tree, 'node, State> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State>
|
||||
impl<'tree, 'node, State: 'static> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State>
|
||||
where
|
||||
'node: 'tree,
|
||||
{
|
||||
@@ -822,3 +973,40 @@ where
|
||||
self.root()
|
||||
}
|
||||
}
|
||||
|
||||
impl<State: 'static> Drop for Harness<'_, State> {
|
||||
fn drop(&mut self) {
|
||||
// Consume SnapshotResults first so its own panic-check runs under our control,
|
||||
// and so `std::thread::panicking()` reflects snapshot failures when plugins observe
|
||||
// the final outcome.
|
||||
#[cfg(feature = "snapshot")]
|
||||
if let Some(results) = self.snapshot_results.take() {
|
||||
// Drop may panic; if so, the panic propagates and plugins still see Fail.
|
||||
drop(results);
|
||||
}
|
||||
|
||||
if self.plugins.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if std::thread::panicking() {
|
||||
plugin::with_fail_test_result(|result| {
|
||||
self.dispatch(|p, h| p.on_test_result(h, fail_ref(&result)));
|
||||
});
|
||||
} else {
|
||||
self.dispatch(|p, h| p.on_test_result(h, TestResult::Pass));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: reborrow a `TestResult::Fail` so it can be passed to multiple plugins from
|
||||
// inside `dispatch`'s FnMut closure.
|
||||
fn fail_ref<'a>(result: &'a TestResult<'a>) -> TestResult<'a> {
|
||||
match result {
|
||||
TestResult::Pass => TestResult::Pass,
|
||||
TestResult::Fail { message, location } => TestResult::Fail {
|
||||
message: *message,
|
||||
location: *location,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use kittest::{AccessKitNode, NodeT, debug_fmt_node};
|
||||
use std::fmt::{Debug, Formatter};
|
||||
|
||||
pub(crate) enum EventType {
|
||||
Event(egui::Event),
|
||||
Modifiers(Modifiers),
|
||||
Event(egui::Event, &'static std::panic::Location<'static>),
|
||||
Modifiers(Modifiers, &'static std::panic::Location<'static>),
|
||||
}
|
||||
|
||||
pub(crate) type EventQueue = Mutex<Vec<EventType>>;
|
||||
@@ -37,27 +37,38 @@ impl<'tree> NodeT<'tree> for Node<'tree> {
|
||||
}
|
||||
|
||||
impl Node<'_> {
|
||||
#[track_caller]
|
||||
fn event(&self, event: egui::Event) {
|
||||
self.queue.lock().push(EventType::Event(event));
|
||||
self.queue
|
||||
.lock()
|
||||
.push(EventType::Event(event, std::panic::Location::caller()));
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn modifiers(&self, modifiers: Modifiers) {
|
||||
self.queue.lock().push(EventType::Modifiers(modifiers));
|
||||
self.queue.lock().push(EventType::Modifiers(
|
||||
modifiers,
|
||||
std::panic::Location::caller(),
|
||||
));
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn hover(&self) {
|
||||
self.event(egui::Event::PointerMoved(self.rect().center()));
|
||||
}
|
||||
|
||||
/// Click at the node center with the primary button.
|
||||
#[track_caller]
|
||||
pub fn click(&self) {
|
||||
self.click_button(PointerButton::Primary);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn click_secondary(&self) {
|
||||
self.click_button(PointerButton::Secondary);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn click_button(&self, button: PointerButton) {
|
||||
self.hover();
|
||||
for pressed in [true, false] {
|
||||
@@ -70,10 +81,12 @@ impl Node<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn click_modifiers(&self, modifiers: Modifiers) {
|
||||
self.click_button_modifiers(PointerButton::Primary, modifiers);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn click_button_modifiers(&self, button: PointerButton, modifiers: Modifiers) {
|
||||
self.hover();
|
||||
self.modifiers(modifiers);
|
||||
@@ -92,6 +105,7 @@ impl Node<'_> {
|
||||
///
|
||||
/// This will trigger a [`accesskit::Action::Click`] action.
|
||||
/// In contrast to `click()`, this can also click widgets that are not currently visible.
|
||||
#[track_caller]
|
||||
pub fn click_accesskit(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(
|
||||
@@ -115,6 +129,7 @@ impl Node<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn focus(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
@@ -125,6 +140,7 @@ impl Node<'_> {
|
||||
}));
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn type_text(&self, text: &str) {
|
||||
self.event(egui::Event::Text(text.to_owned()));
|
||||
}
|
||||
@@ -138,6 +154,7 @@ impl Node<'_> {
|
||||
}
|
||||
|
||||
/// Scroll the node into view.
|
||||
#[track_caller]
|
||||
pub fn scroll_to_me(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
@@ -149,6 +166,7 @@ impl Node<'_> {
|
||||
}
|
||||
|
||||
/// Scroll the [`egui::ScrollArea`] containing this node down (100px).
|
||||
#[track_caller]
|
||||
pub fn scroll_down(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
@@ -160,6 +178,7 @@ impl Node<'_> {
|
||||
}
|
||||
|
||||
/// Scroll the [`egui::ScrollArea`] containing this node up (100px).
|
||||
#[track_caller]
|
||||
pub fn scroll_up(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
@@ -171,6 +190,7 @@ impl Node<'_> {
|
||||
}
|
||||
|
||||
/// Scroll the [`egui::ScrollArea`] containing this node left (100px).
|
||||
#[track_caller]
|
||||
pub fn scroll_left(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
@@ -182,6 +202,7 @@ impl Node<'_> {
|
||||
}
|
||||
|
||||
/// Scroll the [`egui::ScrollArea`] containing this node right (100px).
|
||||
#[track_caller]
|
||||
pub fn scroll_right(&self) {
|
||||
let (target_node, target_tree) = self.accesskit_node.locate();
|
||||
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
|
||||
|
||||
183
crates/egui_kittest/src/plugin.rs
Normal file
183
crates/egui_kittest/src/plugin.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! Plugin system for observing and extending the [`crate::Harness`] test lifecycle.
|
||||
//!
|
||||
//! Implement [`Plugin`] to hook into harness events: frame steps, run loops, events,
|
||||
//! renders, snapshots, and final pass/fail. Register plugins via
|
||||
//! [`crate::HarnessBuilder::with_plugin`] or [`crate::Harness::add_plugin`].
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
use crate::{ExceededMaxStepsError, Harness};
|
||||
|
||||
/// A plugin observes the test-harness lifecycle and can drive additional frames.
|
||||
///
|
||||
/// All methods default to no-ops; implement only the ones you need.
|
||||
///
|
||||
/// State-agnostic plugins should impl for all `State` so they're reusable across harnesses:
|
||||
/// ```
|
||||
/// use egui_kittest::{Harness, Plugin};
|
||||
///
|
||||
/// struct MyPlugin;
|
||||
///
|
||||
/// impl<S> Plugin<S> for MyPlugin {
|
||||
/// fn after_step(&mut self, _harness: &mut Harness<'_, S>) {
|
||||
/// // ...
|
||||
/// }
|
||||
/// fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Re-entrancy
|
||||
///
|
||||
/// Plugin hooks receive `&mut Harness`. Calling [`Harness::step`] / [`Harness::run`] /
|
||||
/// etc. from inside a hook is forbidden (it would recurse infinitely through your own
|
||||
/// `after_step`) and is caught by a `debug_assert!`. If a plugin needs to advance the
|
||||
/// harness from inside a hook — e.g. an inspector that blocks on user input — use
|
||||
/// [`Harness::advance_frame`] instead.
|
||||
#[expect(unused_variables, reason = "default no-op impls")]
|
||||
pub trait Plugin<State = ()>: Send + 'static {
|
||||
/// Called once at the start of every `run()` / `try_run()` / `try_run_realtime()` /
|
||||
/// `run_ok()` invocation, before the first step.
|
||||
fn before_run(&mut self, harness: &mut Harness<'_, State>) {}
|
||||
|
||||
/// Called once after the outer run loop exits (successful completion or
|
||||
/// [`ExceededMaxStepsError`]).
|
||||
fn after_run(
|
||||
&mut self,
|
||||
harness: &mut Harness<'_, State>,
|
||||
result: Result<u64, &ExceededMaxStepsError>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Called immediately before each single-frame step (per-frame, not per public call).
|
||||
fn before_step(&mut self, harness: &mut Harness<'_, State>) {}
|
||||
|
||||
/// Called immediately after each single-frame step.
|
||||
fn after_step(&mut self, harness: &mut Harness<'_, State>) {}
|
||||
|
||||
/// Called after a queued event has been pushed into the harness input, before the
|
||||
/// frame runs that consumes it.
|
||||
fn on_event(&mut self, harness: &mut Harness<'_, State>, event: &egui::Event) {}
|
||||
|
||||
/// Called from inside [`Harness::render`] after the image is produced. Lets a plugin
|
||||
/// observe every rendered frame without triggering a second render pass.
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||
fn on_render(&mut self, harness: &mut Harness<'_, State>, image: &image::RgbaImage) {}
|
||||
|
||||
/// Called from [`Harness::try_snapshot`] / [`Harness::try_snapshot_options`] after
|
||||
/// the comparison has run, before the result is handed back to the caller. The
|
||||
/// `image` is the frame that was compared against the stored snapshot.
|
||||
#[cfg(feature = "snapshot")]
|
||||
fn on_snapshot(
|
||||
&mut self,
|
||||
harness: &mut Harness<'_, State>,
|
||||
name: &str,
|
||||
image: &image::RgbaImage,
|
||||
result: &crate::SnapshotResult,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Called exactly once, from [`Harness::drop`], after the harness has finalized its
|
||||
/// snapshot results. `result` is [`TestResult::Pass`] unless a panic is in progress
|
||||
/// on this thread, in which case it's [`TestResult::Fail`].
|
||||
///
|
||||
/// The `message` and `location` fields of `Fail` are only populated if the user has
|
||||
/// called [`install_panic_hook`]. Without the hook, the variant still flips to
|
||||
/// `Fail` but both fields are `None`.
|
||||
fn on_test_result(&mut self, harness: &mut Harness<'_, State>, result: TestResult<'_>) {}
|
||||
|
||||
/// Downcast support — implement as `self`.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
/// Downcast support — implement as `self`.
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
}
|
||||
|
||||
/// Location of a panic — a `std::panic::Location` stripped of its borrow so it can be
|
||||
/// stored in a thread-local and handed to plugins.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PanicLocation {
|
||||
pub file: String,
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// Outcome of a test, as seen by [`Plugin::on_test_result`].
|
||||
#[derive(Debug)]
|
||||
pub enum TestResult<'a> {
|
||||
/// No panic in progress on this thread when `on_test_result` fired.
|
||||
Pass,
|
||||
|
||||
/// A panic is in progress on this thread.
|
||||
///
|
||||
/// `message` and `location` are populated only if [`install_panic_hook`] has been
|
||||
/// called (once, process-wide) before the panic occurred.
|
||||
Fail {
|
||||
message: Option<&'a str>,
|
||||
location: Option<&'a PanicLocation>,
|
||||
},
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Opt-in panic hook for capturing the panic message + location so plugins can report them.
|
||||
//
|
||||
// Installing a `std::panic::set_hook` from library code is a process-wide side effect, so we
|
||||
// do NOT install it automatically. Users opt in once (e.g. from a test main or `#[ctor]`).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
thread_local! {
|
||||
static LAST_PANIC: RefCell<Option<PanicRecord>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct PanicRecord {
|
||||
message: Option<String>,
|
||||
location: Option<PanicLocation>,
|
||||
}
|
||||
|
||||
static INSTALLED: OnceLock<()> = OnceLock::new();
|
||||
|
||||
/// Install a `std::panic::set_hook` that captures each panic's message and location into
|
||||
/// a thread-local, which [`Plugin::on_test_result`] then reads into its `Fail` variant.
|
||||
///
|
||||
/// Process-wide and idempotent (subsequent calls are no-ops). Chains to whatever hook was
|
||||
/// previously installed, so existing output is preserved.
|
||||
pub fn install_panic_hook() {
|
||||
INSTALLED.get_or_init(|| {
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let message = info
|
||||
.payload()
|
||||
.downcast_ref::<&'static str>()
|
||||
.map(|s| (*s).to_owned())
|
||||
.or_else(|| info.payload().downcast_ref::<String>().cloned());
|
||||
let location = info.location().map(|loc| PanicLocation {
|
||||
file: loc.file().to_owned(),
|
||||
line: loc.line(),
|
||||
column: loc.column(),
|
||||
});
|
||||
LAST_PANIC.with(|slot| {
|
||||
*slot.borrow_mut() = Some(PanicRecord { message, location });
|
||||
});
|
||||
prev(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Called from [`Harness::drop`] when `std::thread::panicking()` is true. Builds a
|
||||
/// [`TestResult::Fail`] borrowing from the thread-local panic record, invokes `f` with
|
||||
/// it, then restores the record.
|
||||
///
|
||||
/// We have to invoke via callback (rather than returning the `Fail`) because the borrows
|
||||
/// live inside the thread-local's `RefCell`.
|
||||
pub(crate) fn with_fail_test_result<R>(f: impl FnOnce(TestResult<'_>) -> R) -> R {
|
||||
LAST_PANIC.with(|slot| {
|
||||
let borrow = slot.borrow();
|
||||
let (message, location) = match borrow.as_ref() {
|
||||
Some(rec) => (rec.message.as_deref(), rec.location.as_ref()),
|
||||
None => (None, None),
|
||||
};
|
||||
f(TestResult::Fail { message, location })
|
||||
})
|
||||
}
|
||||
@@ -590,7 +590,7 @@ pub fn image_snapshot(current: &image::RgbaImage, name: impl Into<String>) {
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||
impl<State> Harness<'_, State> {
|
||||
impl<State: 'static> Harness<'_, State> {
|
||||
/// The default options used for snapshot tests.
|
||||
/// set by [`crate::HarnessBuilder::with_options`].
|
||||
pub fn options(&self) -> &SnapshotOptions {
|
||||
@@ -622,10 +622,14 @@ impl<State> Harness<'_, State> {
|
||||
name: impl Into<String>,
|
||||
options: &SnapshotOptions,
|
||||
) -> SnapshotResult {
|
||||
let image = self
|
||||
.render()
|
||||
.map_err(|err| SnapshotError::RenderError { err })?;
|
||||
try_image_snapshot_options(&image, name.into(), options)
|
||||
let name = name.into();
|
||||
let image = match self.render() {
|
||||
Ok(img) => img,
|
||||
Err(err) => return Err(SnapshotError::RenderError { err }),
|
||||
};
|
||||
let result = try_image_snapshot_options(&image, name.clone(), options);
|
||||
self.dispatch(|p, h| p.on_snapshot(h, &name, &image, &result));
|
||||
result
|
||||
}
|
||||
|
||||
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot.
|
||||
@@ -640,10 +644,8 @@ impl<State> Harness<'_, State> {
|
||||
/// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an
|
||||
/// error reading or writing the snapshot, if the rendering fails or if no default renderer is available.
|
||||
pub fn try_snapshot(&mut self, name: impl Into<String>) -> SnapshotResult {
|
||||
let image = self
|
||||
.render()
|
||||
.map_err(|err| SnapshotError::RenderError { err })?;
|
||||
try_image_snapshot_options(&image, name.into(), &self.default_snapshot_options)
|
||||
let options = self.default_snapshot_options.clone();
|
||||
self.try_snapshot_options(name, &options)
|
||||
}
|
||||
|
||||
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot
|
||||
@@ -673,7 +675,10 @@ impl<State> Harness<'_, State> {
|
||||
#[track_caller]
|
||||
pub fn snapshot_options(&mut self, name: impl Into<String>, options: &SnapshotOptions) {
|
||||
let result = self.try_snapshot_options(name, options);
|
||||
self.snapshot_results.add(result);
|
||||
self.snapshot_results
|
||||
.as_mut()
|
||||
.expect("SnapshotResults already taken")
|
||||
.add(result);
|
||||
}
|
||||
|
||||
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot.
|
||||
@@ -690,7 +695,10 @@ impl<State> Harness<'_, State> {
|
||||
#[track_caller]
|
||||
pub fn snapshot(&mut self, name: impl Into<String>) {
|
||||
let result = self.try_snapshot(name);
|
||||
self.snapshot_results.add(result);
|
||||
self.snapshot_results
|
||||
.as_mut()
|
||||
.expect("SnapshotResults already taken")
|
||||
.add(result);
|
||||
}
|
||||
|
||||
/// Render a snapshot, save it to a temp file and open it in the default image viewer.
|
||||
@@ -743,7 +751,10 @@ impl<State> Harness<'_, State> {
|
||||
/// This removes the snapshot results from the harness. Useful if you e.g. want to merge it
|
||||
/// with the results from another harness (using [`SnapshotResults::add`]).
|
||||
pub fn take_snapshot_results(&mut self) -> SnapshotResults {
|
||||
std::mem::take(&mut self.snapshot_results)
|
||||
// Replace with a fresh SnapshotResults so subsequent snapshot calls don't panic.
|
||||
self.snapshot_results
|
||||
.replace(SnapshotResults::default())
|
||||
.expect("SnapshotResults already taken")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
313
crates/egui_kittest/tests/plugin.rs
Normal file
313
crates/egui_kittest/tests/plugin.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::disallowed_methods,
|
||||
clippy::disallowed_types,
|
||||
clippy::clone_on_ref_ptr
|
||||
)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::{ExceededMaxStepsError, Harness, Plugin, TestResult};
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CountingPlugin {
|
||||
log: Log,
|
||||
}
|
||||
|
||||
impl CountingPlugin {
|
||||
fn new() -> (Self, Log) {
|
||||
let log: Log = Arc::default();
|
||||
(Self { log: log.clone() }, log)
|
||||
}
|
||||
|
||||
fn push(&self, tag: &str) {
|
||||
self.log.lock().unwrap().push(tag.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Plugin<S> for CountingPlugin {
|
||||
fn before_run(&mut self, _h: &mut Harness<'_, S>) {
|
||||
self.push("before_run");
|
||||
}
|
||||
fn after_run(&mut self, _h: &mut Harness<'_, S>, result: Result<u64, &ExceededMaxStepsError>) {
|
||||
self.push(if result.is_ok() {
|
||||
"after_run:ok"
|
||||
} else {
|
||||
"after_run:err"
|
||||
});
|
||||
}
|
||||
fn before_step(&mut self, _h: &mut Harness<'_, S>) {
|
||||
self.push("before_step");
|
||||
}
|
||||
fn after_step(&mut self, _h: &mut Harness<'_, S>) {
|
||||
self.push("after_step");
|
||||
}
|
||||
fn on_event(&mut self, _h: &mut Harness<'_, S>, _event: &egui::Event) {
|
||||
self.push("on_event");
|
||||
}
|
||||
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||
fn on_render(&mut self, _h: &mut Harness<'_, S>, _image: &image::RgbaImage) {
|
||||
self.push("on_render");
|
||||
}
|
||||
#[cfg(feature = "snapshot")]
|
||||
fn on_snapshot(
|
||||
&mut self,
|
||||
_h: &mut Harness<'_, S>,
|
||||
name: &str,
|
||||
_image: &image::RgbaImage,
|
||||
result: &egui_kittest::SnapshotResult,
|
||||
) {
|
||||
self.push(&format!(
|
||||
"on_snapshot:{}:{}",
|
||||
name,
|
||||
if result.is_ok() { "ok" } else { "err" }
|
||||
));
|
||||
}
|
||||
fn on_test_result(&mut self, _h: &mut Harness<'_, S>, result: TestResult<'_>) {
|
||||
self.push(match result {
|
||||
TestResult::Pass => "on_test_result:pass",
|
||||
TestResult::Fail { .. } => "on_test_result:fail",
|
||||
});
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle ordering: a simple run+drop cycle fires the expected hooks in order.
|
||||
#[test]
|
||||
fn hooks_fire_in_expected_order() {
|
||||
let (plugin, log) = CountingPlugin::new();
|
||||
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
|
||||
harness.run();
|
||||
drop(harness);
|
||||
|
||||
let log = log.lock().unwrap().clone();
|
||||
// Construction calls `run_ok()`, so the first batch of hooks fires during `new_ui`:
|
||||
// before_run, before_step, after_step, after_run
|
||||
// Then `harness.run()` fires another set.
|
||||
// Then Drop fires `on_test_result:pass`.
|
||||
assert_eq!(log.first().map(String::as_str), Some("before_run"));
|
||||
assert!(log.contains(&"before_step".to_owned()));
|
||||
assert!(log.contains(&"after_step".to_owned()));
|
||||
assert!(log.contains(&"after_run:ok".to_owned()));
|
||||
assert_eq!(log.last().map(String::as_str), Some("on_test_result:pass"));
|
||||
|
||||
// Every before_step has a matching after_step.
|
||||
let befores = log.iter().filter(|s| s == &"before_step").count();
|
||||
let afters = log.iter().filter(|s| s == &"after_step").count();
|
||||
assert_eq!(befores, afters);
|
||||
}
|
||||
|
||||
/// `on_event` fires per queued event.
|
||||
#[test]
|
||||
fn on_event_fires_per_event() {
|
||||
let (plugin, log) = CountingPlugin::new();
|
||||
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
|
||||
log.lock().unwrap().clear(); // drop construction-time hooks
|
||||
harness.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0)));
|
||||
harness.event(egui::Event::PointerMoved(egui::pos2(20.0, 20.0)));
|
||||
harness.step();
|
||||
|
||||
let log = log.lock().unwrap();
|
||||
let events = log.iter().filter(|s| s == &"on_event").count();
|
||||
assert_eq!(events, 2, "expected 2 on_event calls, got log: {log:?}");
|
||||
}
|
||||
|
||||
/// `advance_frame` does NOT fire `before_step`/`after_step`.
|
||||
#[test]
|
||||
fn advance_frame_skips_hooks() {
|
||||
struct DrivingPlugin {
|
||||
log: Log,
|
||||
drove: bool,
|
||||
}
|
||||
impl<S> Plugin<S> for DrivingPlugin {
|
||||
fn after_step(&mut self, h: &mut Harness<'_, S>) {
|
||||
self.log.lock().unwrap().push("after_step".into());
|
||||
if !self.drove {
|
||||
self.drove = true;
|
||||
// Call advance_frame from inside a hook — must not recurse.
|
||||
h.advance_frame();
|
||||
}
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let mut harness = Harness::builder()
|
||||
.with_plugin(DrivingPlugin {
|
||||
log: log.clone(),
|
||||
drove: false,
|
||||
})
|
||||
.build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
|
||||
log.lock().unwrap().clear();
|
||||
harness.step();
|
||||
|
||||
let log = log.lock().unwrap();
|
||||
// Exactly one after_step from the user's step(), plus any from construction-time run_ok
|
||||
// (cleared above). advance_frame must NOT have produced another after_step.
|
||||
assert_eq!(log.iter().filter(|s| s == &"after_step").count(), 1);
|
||||
}
|
||||
|
||||
/// Registering a plugin inside a hook defers it to the next dispatch.
|
||||
#[test]
|
||||
fn mid_dispatch_registration_is_deferred() {
|
||||
struct Registrar {
|
||||
log: Log,
|
||||
registered: bool,
|
||||
}
|
||||
impl<S: 'static> Plugin<S> for Registrar {
|
||||
fn after_step(&mut self, h: &mut Harness<'_, S>) {
|
||||
self.log.lock().unwrap().push("registrar:after_step".into());
|
||||
if !self.registered {
|
||||
self.registered = true;
|
||||
let (latecomer, latecomer_log) = CountingPlugin::new();
|
||||
// Share the same log so we can see its hooks interleave.
|
||||
*latecomer.log.lock().unwrap() = std::mem::take(&mut self.log.lock().unwrap());
|
||||
self.log = latecomer.log.clone();
|
||||
let _ = latecomer_log; // dropped
|
||||
h.add_plugin(latecomer);
|
||||
}
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let mut harness = Harness::builder()
|
||||
.with_plugin(Registrar {
|
||||
log: log.clone(),
|
||||
registered: false,
|
||||
})
|
||||
.build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
|
||||
harness.step(); // registrar hooks fire here; latecomer gets registered
|
||||
// The latecomer should NOT see this step's hooks (it was registered mid-dispatch).
|
||||
// On the next step, it should start seeing hooks.
|
||||
|
||||
// Easier assertion: before the second step, the latecomer shouldn't have produced
|
||||
// any "before_step" entries. Since we merged logs, we can't easily isolate — instead,
|
||||
// verify the harness does not deadlock / recurse.
|
||||
harness.step();
|
||||
assert!(harness.plugin::<CountingPlugin>().is_some());
|
||||
}
|
||||
|
||||
/// Downcasting via `plugin::<P>()` / `plugin_mut::<P>()` / `take_plugin::<P>()`.
|
||||
#[test]
|
||||
fn downcast_plugin_by_type() {
|
||||
let (plugin, _log) = CountingPlugin::new();
|
||||
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
|
||||
assert!(harness.plugin::<CountingPlugin>().is_some());
|
||||
assert!(harness.plugin_mut::<CountingPlugin>().is_some());
|
||||
let taken = harness.take_plugin::<CountingPlugin>();
|
||||
assert!(taken.is_some());
|
||||
assert!(harness.plugin::<CountingPlugin>().is_none());
|
||||
}
|
||||
|
||||
/// When `Harness::drop` fires while a panic is unwinding, `on_test_result` gets `Fail`.
|
||||
#[test]
|
||||
fn on_test_result_sees_panic() {
|
||||
let (plugin, log) = CountingPlugin::new();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
// Panic while the harness is alive so its Drop runs during unwind.
|
||||
panic!("kaboom");
|
||||
}));
|
||||
|
||||
assert!(result.is_err());
|
||||
let log = log.lock().unwrap();
|
||||
let last = log.last().map(String::as_str);
|
||||
assert_eq!(last, Some("on_test_result:fail"), "log = {log:?}");
|
||||
}
|
||||
|
||||
/// Calling `Harness::step` from inside a plugin hook should panic in debug builds
|
||||
/// (the `in_dispatch` guard).
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
#[should_panic(expected = "inside a plugin hook")]
|
||||
fn reentrant_step_panics_in_debug() {
|
||||
struct Misbehaver;
|
||||
impl<S: 'static> Plugin<S> for Misbehaver {
|
||||
fn after_step(&mut self, h: &mut Harness<'_, S>) {
|
||||
// Forbidden: step from inside a hook.
|
||||
h.step();
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
let mut harness = Harness::builder().with_plugin(Misbehaver).build_ui(|ui| {
|
||||
ui.label("hi");
|
||||
});
|
||||
harness.step();
|
||||
}
|
||||
|
||||
/// `on_snapshot` fires with an Err result for a missing snapshot.
|
||||
#[cfg(feature = "snapshot")]
|
||||
#[test]
|
||||
fn on_snapshot_fires_with_err_for_missing() {
|
||||
let (plugin, log) = CountingPlugin::new();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut harness = Harness::builder()
|
||||
.wgpu()
|
||||
.with_plugin(plugin)
|
||||
.with_options(
|
||||
egui_kittest::SnapshotOptions::default().output_path(tmp.path().to_path_buf()),
|
||||
)
|
||||
.build_ui(|ui| {
|
||||
ui.label("snap");
|
||||
});
|
||||
|
||||
let result = harness.try_snapshot("nonexistent_snapshot_for_plugin_test");
|
||||
// Expect Err (no snapshot file exists in tmpdir).
|
||||
assert!(result.is_err(), "expected snapshot err, got {result:?}");
|
||||
|
||||
let log = log.lock().unwrap();
|
||||
let on_snapshot_entry = log
|
||||
.iter()
|
||||
.find(|s| s.starts_with("on_snapshot:"))
|
||||
.expect("on_snapshot should have been logged");
|
||||
assert!(
|
||||
on_snapshot_entry.ends_with(":err"),
|
||||
"entry = {on_snapshot_entry}"
|
||||
);
|
||||
assert!(
|
||||
on_snapshot_entry.contains("nonexistent_snapshot_for_plugin_test"),
|
||||
"entry should contain the snapshot name: {on_snapshot_entry}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user