mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
Add kittest recording feature
This commit is contained in:
@@ -26,6 +26,13 @@ wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "dep:wgpu", "eframe?/wgpu"
|
|||||||
## Adds a dify-based image snapshot utility.
|
## Adds a dify-based image snapshot utility.
|
||||||
snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
|
snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
|
||||||
|
|
||||||
|
## Record a test session as an animated GIF or a sequence of PNG files.
|
||||||
|
##
|
||||||
|
## Needs a renderer, so enable the `wgpu` feature too, unless you bring your own.
|
||||||
|
## Every harness then follows the egui textures, so that a recording can start at any time,
|
||||||
|
## but nothing is rendered until a recording starts.
|
||||||
|
recording = ["snapshot", "dep:image", "image/gif"]
|
||||||
|
|
||||||
## Allows testing eframe::App
|
## Allows testing eframe::App
|
||||||
eframe = ["dep:eframe", "eframe/accesskit"]
|
eframe = ["dep:eframe", "eframe/accesskit"]
|
||||||
|
|
||||||
@@ -63,6 +70,7 @@ tempfile = { workspace = true, optional = true }
|
|||||||
egui = { workspace = true, features = ["default_fonts"] }
|
egui = { workspace = true, features = ["default_fonts"] }
|
||||||
image = { workspace = true, features = ["png"] }
|
image = { workspace = true, features = ["png"] }
|
||||||
egui_extras = { workspace = true, features = ["image", "http"] }
|
egui_extras = { workspace = true, features = ["image", "http"] }
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ max_failed_pixels = 0
|
|||||||
[linux]
|
[linux]
|
||||||
threshold = 0.6
|
threshold = 0.6
|
||||||
max_failed_pixels = 0
|
max_failed_pixels = 0
|
||||||
|
|
||||||
|
# record every test and save a GIF of the failing ones
|
||||||
|
# (needs the `recording` feature)
|
||||||
|
save_gif_on_failure = false
|
||||||
```
|
```
|
||||||
|
|
||||||
Raise `max_failed_pixels` only very carefully: a high value (more than ~10) is enough to hide a
|
Raise `max_failed_pixels` only very carefully: a high value (more than ~10) is enough to hide a
|
||||||
@@ -70,6 +74,35 @@ real change, such as a moved separator, a shifted one-pixel border, or a small i
|
|||||||
incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever you
|
incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever you
|
||||||
update the snapshot.
|
update the snapshot.
|
||||||
|
|
||||||
|
## Recording
|
||||||
|
|
||||||
|
With the `recording` feature you can record a test as an animated GIF, which is useful to see
|
||||||
|
what a test actually does. Recording renders every egui pass, so it needs a renderer:
|
||||||
|
enable the `wgpu` feature too.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use egui_kittest::Harness;
|
||||||
|
# let mut harness = Harness::new_ui(|ui| { ui.label("Hello!"); });
|
||||||
|
#[cfg(all(feature = "recording", feature = "wgpu"))]
|
||||||
|
{
|
||||||
|
use egui_kittest::RecordingOptions;
|
||||||
|
|
||||||
|
harness.start_recording(RecordingOptions::gif("hello.gif", 10.0));
|
||||||
|
harness.run();
|
||||||
|
harness.finish_recording().unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also record without touching the test:
|
||||||
|
|
||||||
|
* `KITTEST_RECORD=1 cargo test` writes a GIF per test to `tests/snapshots/recordings`
|
||||||
|
* `KITTEST_RECORD=open cargo test` writes each GIF to a temporary file and opens it
|
||||||
|
* `save_gif_on_failure = true` in `kittest.toml` writes a GIF to `tests/snapshots/failures`,
|
||||||
|
but only for tests that fail
|
||||||
|
|
||||||
|
The recorder is an `egui::Plugin` (`RecordingPlugin`), so you can also register it on any
|
||||||
|
`egui::Context` yourself.
|
||||||
|
|
||||||
## Snapshot testing
|
## Snapshot testing
|
||||||
There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features.
|
There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features.
|
||||||
Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory.
|
Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory.
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ pub struct Config {
|
|||||||
#[serde(alias = "failed_pixel_count_threshold")]
|
#[serde(alias = "failed_pixel_count_threshold")]
|
||||||
max_failed_pixels: usize,
|
max_failed_pixels: usize,
|
||||||
|
|
||||||
|
/// Record every harness and save a GIF of the failing ones to
|
||||||
|
/// `{output_path}/failures/{test_name}.gif`.
|
||||||
|
///
|
||||||
|
/// Needs the `recording` feature; ignored without it.
|
||||||
|
///
|
||||||
|
/// Default is `false`.
|
||||||
|
#[cfg_attr(not(feature = "recording"), expect(dead_code))]
|
||||||
|
save_gif_on_failure: bool,
|
||||||
|
|
||||||
windows: OsConfig,
|
windows: OsConfig,
|
||||||
mac: OsConfig,
|
mac: OsConfig,
|
||||||
linux: OsConfig,
|
linux: OsConfig,
|
||||||
@@ -41,6 +50,7 @@ impl Default for Config {
|
|||||||
output_path: PathBuf::from("tests/snapshots"),
|
output_path: PathBuf::from("tests/snapshots"),
|
||||||
threshold: 0.6,
|
threshold: 0.6,
|
||||||
max_failed_pixels: 0,
|
max_failed_pixels: 0,
|
||||||
|
save_gif_on_failure: false,
|
||||||
windows: Default::default(),
|
windows: Default::default(),
|
||||||
mac: Default::default(),
|
mac: Default::default(),
|
||||||
linux: Default::default(),
|
linux: Default::default(),
|
||||||
@@ -150,6 +160,15 @@ impl Config {
|
|||||||
pub fn output_path(&self) -> PathBuf {
|
pub fn output_path(&self) -> PathBuf {
|
||||||
self.output_path.clone()
|
self.output_path.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record every harness and save a GIF of the failing ones to
|
||||||
|
/// `{output_path}/failures/{test_name}.gif`.
|
||||||
|
///
|
||||||
|
/// Default is `false`.
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
pub fn save_gif_on_failure(&self) -> bool {
|
||||||
|
self.save_gif_on_failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "snapshot")]
|
#[cfg(feature = "snapshot")]
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ pub use crate::snapshot::*;
|
|||||||
mod app_kind;
|
mod app_kind;
|
||||||
mod config;
|
mod config;
|
||||||
mod node;
|
mod node;
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
mod recording;
|
||||||
mod renderer;
|
mod renderer;
|
||||||
#[cfg(feature = "wgpu")]
|
#[cfg(feature = "wgpu")]
|
||||||
mod texture_to_image;
|
mod texture_to_image;
|
||||||
#[cfg(feature = "wgpu")]
|
#[cfg(feature = "wgpu")]
|
||||||
pub mod wgpu;
|
pub mod wgpu;
|
||||||
|
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
pub use crate::recording::{
|
||||||
|
RECORD_ENV_VAR, RecordKind, RecordingError, RecordingOptions, RecordingPlugin, RecordingTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
// re-exports:
|
// re-exports:
|
||||||
pub use {
|
pub use {
|
||||||
self::{builder::*, node::*, renderer::*},
|
self::{builder::*, node::*, renderer::*},
|
||||||
@@ -87,6 +94,13 @@ pub struct Harness<'a, State = ()> {
|
|||||||
default_snapshot_options: SnapshotOptions,
|
default_snapshot_options: SnapshotOptions,
|
||||||
#[cfg(feature = "snapshot")]
|
#[cfg(feature = "snapshot")]
|
||||||
snapshot_results: SnapshotResults,
|
snapshot_results: SnapshotResults,
|
||||||
|
|
||||||
|
/// Saves an automatically started recording when the harness is dropped.
|
||||||
|
///
|
||||||
|
/// Must be declared after `snapshot_results`: fields are dropped in order, so this way
|
||||||
|
/// the panic from a failed snapshot happens first and we can detect it.
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
recording_auto_save: Option<recording::AutoSaveOnDrop>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<State> Debug for Harness<'_, State> {
|
impl<State> Debug for Harness<'_, State> {
|
||||||
@@ -139,6 +153,11 @@ impl<'a, State> Harness<'a, State> {
|
|||||||
let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap();
|
let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap();
|
||||||
viewport.native_pixels_per_point = Some(pixels_per_point);
|
viewport.native_pixels_per_point = Some(pixels_per_point);
|
||||||
|
|
||||||
|
// Follow the textures from the very first pass, so that a recording can be started
|
||||||
|
// at any time. This captures nothing until the recording starts.
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
recording::install_idle(&ctx);
|
||||||
|
|
||||||
let mut response = None;
|
let mut response = None;
|
||||||
|
|
||||||
// We need to run egui for a single frame so that the AccessKit state can be initialized
|
// We need to run egui for a single frame so that the AccessKit state can be initialized
|
||||||
@@ -174,6 +193,9 @@ impl<'a, State> Harness<'a, State> {
|
|||||||
|
|
||||||
#[cfg(feature = "snapshot")]
|
#[cfg(feature = "snapshot")]
|
||||||
snapshot_results: SnapshotResults::default(),
|
snapshot_results: SnapshotResults::default(),
|
||||||
|
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
recording_auto_save: None,
|
||||||
};
|
};
|
||||||
// Handle any viewport commands (e.g. a screenshot or resize) requested during the initial
|
// Handle any viewport commands (e.g. a screenshot or resize) requested during the initial
|
||||||
// frame above (which didn't go through `_step`).
|
// frame above (which didn't go through `_step`).
|
||||||
@@ -181,6 +203,11 @@ impl<'a, State> Harness<'a, State> {
|
|||||||
|
|
||||||
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
|
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
|
||||||
harness.run_ok();
|
harness.run_ok();
|
||||||
|
|
||||||
|
// Start recording only now, so that the setup frames above are not part of the recording.
|
||||||
|
#[cfg(feature = "recording")]
|
||||||
|
harness.maybe_start_auto_recording();
|
||||||
|
|
||||||
harness
|
harness
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,26 +670,7 @@ impl<'a, State> Harness<'a, State> {
|
|||||||
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||||
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
|
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
|
||||||
let mut output = self.output.clone();
|
let mut output = self.output.clone();
|
||||||
|
push_cursor_shape(&self.ctx, &mut output.shapes);
|
||||||
if let Some(mouse_pos) = self.ctx.input(|i| i.pointer.hover_pos()) {
|
|
||||||
// Paint a mouse cursor:
|
|
||||||
let triangle = vec![
|
|
||||||
mouse_pos,
|
|
||||||
mouse_pos + egui::vec2(16.0, 8.0),
|
|
||||||
mouse_pos + egui::vec2(8.0, 16.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
output.shapes.push(ClippedShape {
|
|
||||||
clip_rect: self.ctx.content_rect(),
|
|
||||||
shape: egui::epaint::PathShape::convex_polygon(
|
|
||||||
triangle,
|
|
||||||
Color32::WHITE,
|
|
||||||
egui::Stroke::new(1.0, Color32::BLACK),
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
self.renderer.render(&self.ctx, &output)
|
self.renderer.render(&self.ctx, &output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,6 +898,31 @@ impl<'a> Harness<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paint a mouse cursor at the current pointer position.
|
||||||
|
///
|
||||||
|
/// The test harness has no real cursor, so we draw one to show where the pointer is
|
||||||
|
/// in screenshots and recordings.
|
||||||
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||||
|
pub(crate) fn push_cursor_shape(ctx: &egui::Context, shapes: &mut Vec<ClippedShape>) {
|
||||||
|
if let Some(mouse_pos) = ctx.input(|i| i.pointer.hover_pos()) {
|
||||||
|
let triangle = vec![
|
||||||
|
mouse_pos,
|
||||||
|
mouse_pos + egui::vec2(16.0, 8.0),
|
||||||
|
mouse_pos + egui::vec2(8.0, 16.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
shapes.push(ClippedShape {
|
||||||
|
clip_rect: ctx.content_rect(),
|
||||||
|
shape: egui::epaint::PathShape::convex_polygon(
|
||||||
|
triangle,
|
||||||
|
Color32::WHITE,
|
||||||
|
egui::Stroke::new(1.0, Color32::BLACK),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a rendered [`image::RgbaImage`] (premultiplied alpha, as produced by the renderer)
|
/// Convert a rendered [`image::RgbaImage`] (premultiplied alpha, as produced by the renderer)
|
||||||
/// into an [`egui::ColorImage`] suitable for [`egui::Event::Screenshot`].
|
/// into an [`egui::ColorImage`] suitable for [`egui::Event::Screenshot`].
|
||||||
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
|
||||||
|
|||||||
774
crates/egui_kittest/src/recording.rs
Normal file
774
crates/egui_kittest/src/recording.rs
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
//! Record an egui session as an animated GIF or a sequence of PNG files.
|
||||||
|
//!
|
||||||
|
//! The recorder is an [`egui::Plugin`], so it can record any [`egui::Context`],
|
||||||
|
//! not just a [`crate::Harness`]. It renders every pass with its own [`TestRenderer`]
|
||||||
|
//! and keeps the frames in memory until you save them.
|
||||||
|
//!
|
||||||
|
//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`].
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufWriter;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use egui::{Context, FullOutput, TexturesDelta};
|
||||||
|
use image::RgbaImage;
|
||||||
|
use image::codecs::gif::{GifEncoder, Repeat};
|
||||||
|
|
||||||
|
use crate::TestRenderer;
|
||||||
|
|
||||||
|
/// Name of the environment variable that records every [`crate::Harness`] in the process.
|
||||||
|
///
|
||||||
|
/// Every harness records itself and saves a GIF when it is dropped,
|
||||||
|
/// whether the test passed or not:
|
||||||
|
///
|
||||||
|
/// - `KITTEST_RECORD=1` writes to `{output_path}/recordings/{test_name}.gif`
|
||||||
|
/// - `KITTEST_RECORD=open` writes to a temporary file and shows it in the default image viewer
|
||||||
|
pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD";
|
||||||
|
|
||||||
|
/// What to write when the recording is saved.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum RecordKind {
|
||||||
|
/// Save an animated GIF to `path` (looping forever).
|
||||||
|
Gif {
|
||||||
|
/// Where to write the GIF.
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Frames per second. The GIF format stores delays in 10 ms ticks,
|
||||||
|
/// so a frame rate that is not a divisor of 100 is approximated.
|
||||||
|
frame_rate: f32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Save a sequence of PNG files (`frame_0000.png`, `frame_0001.png`, …) into `directory`.
|
||||||
|
PngSequence {
|
||||||
|
/// Directory to write the PNG files into. It is created if it is missing.
|
||||||
|
directory: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which passes to capture.
|
||||||
|
///
|
||||||
|
/// Passes that egui discards (see [`egui::Context::request_discard`]) are never captured,
|
||||||
|
/// since they are never shown to the user either.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub enum RecordingTrigger {
|
||||||
|
/// Capture every pass, but drop a frame if it looks exactly like the frame before it.
|
||||||
|
///
|
||||||
|
/// This is the default. It gives the smallest recordings, because most passes
|
||||||
|
/// change nothing on screen.
|
||||||
|
#[default]
|
||||||
|
ChangedFrames,
|
||||||
|
|
||||||
|
/// Capture every pass, even if nothing changed.
|
||||||
|
EveryFrame,
|
||||||
|
|
||||||
|
/// Capture every `N`-th pass. `EveryNthFrame(1)` is the same as [`Self::EveryFrame`].
|
||||||
|
EveryNthFrame(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to record. Pass this to [`crate::Harness::start_recording`] or [`RecordingPlugin::new`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecordingOptions {
|
||||||
|
/// What to write when the recording is saved.
|
||||||
|
pub kind: RecordKind,
|
||||||
|
|
||||||
|
/// Which passes to capture. Defaults to [`RecordingTrigger::ChangedFrames`].
|
||||||
|
pub trigger: RecordingTrigger,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingOptions {
|
||||||
|
/// Record a GIF to `path` at the given frame rate,
|
||||||
|
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
|
||||||
|
pub fn gif(path: impl Into<PathBuf>, frame_rate: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: RecordKind::Gif {
|
||||||
|
path: path.into(),
|
||||||
|
frame_rate,
|
||||||
|
},
|
||||||
|
trigger: RecordingTrigger::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a PNG sequence into `directory`,
|
||||||
|
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
|
||||||
|
pub fn png_sequence(directory: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: RecordKind::PngSequence {
|
||||||
|
directory: directory.into(),
|
||||||
|
},
|
||||||
|
trigger: RecordingTrigger::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the trigger.
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_trigger(mut self, trigger: RecordingTrigger) -> Self {
|
||||||
|
self.trigger = trigger;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What went wrong when saving a recording.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RecordingError {
|
||||||
|
/// No recording was running.
|
||||||
|
NotRecording,
|
||||||
|
|
||||||
|
/// The recording did not capture a single frame.
|
||||||
|
NoFrames,
|
||||||
|
|
||||||
|
/// Failed to create or write the output file or directory.
|
||||||
|
Io {
|
||||||
|
/// The file or directory we failed to write.
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// The underlying error.
|
||||||
|
err: std::io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Failed to encode the image data.
|
||||||
|
Encode(image::ImageError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RecordingError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotRecording => write!(f, "No recording is running"),
|
||||||
|
Self::NoFrames => write!(f, "The recording contains no frames"),
|
||||||
|
Self::Io { path, err } => write!(f, "Failed to write {}: {err}", path.display()),
|
||||||
|
Self::Encode(err) => write!(f, "Failed to encode the recording: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RecordingError {}
|
||||||
|
|
||||||
|
impl From<image::ImageError> for RecordingError {
|
||||||
|
fn from(err: image::ImageError) -> Self {
|
||||||
|
Self::Encode(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an [`egui::Context`] by rendering each pass to an image.
|
||||||
|
///
|
||||||
|
/// Register it with [`egui::Context::add_plugin`], or let [`crate::Harness::start_recording`]
|
||||||
|
/// do it for you.
|
||||||
|
///
|
||||||
|
/// The plugin renders with its own [`TestRenderer`] (a `wgpu` one by default), so it does not
|
||||||
|
/// interfere with the renderer of the harness.
|
||||||
|
pub struct RecordingPlugin {
|
||||||
|
options: RecordingOptions,
|
||||||
|
renderer: LazyRenderer,
|
||||||
|
frames: Vec<RgbaImage>,
|
||||||
|
pass_nr: u32,
|
||||||
|
|
||||||
|
/// While `false` the plugin still tracks textures, but captures no frames.
|
||||||
|
active: bool,
|
||||||
|
|
||||||
|
/// Did we give our renderer the whole font atlas?
|
||||||
|
///
|
||||||
|
/// A plugin that is registered after the first pass never saw the font texture being
|
||||||
|
/// allocated, only the partial updates that follow it.
|
||||||
|
uploaded_font_atlas: bool,
|
||||||
|
|
||||||
|
/// Set when the harness started the recording by itself (see [`crate::Harness`]).
|
||||||
|
pub(crate) auto_save: Option<AutoSaveMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RecordingPlugin {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("RecordingPlugin")
|
||||||
|
.field("options", &self.options)
|
||||||
|
.field("active", &self.active)
|
||||||
|
.field("frames", &self.frames.len())
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingPlugin {
|
||||||
|
/// Create a plugin that starts recording right away.
|
||||||
|
pub fn new(options: RecordingOptions) -> Self {
|
||||||
|
Self {
|
||||||
|
options,
|
||||||
|
active: true,
|
||||||
|
..Self::idle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a plugin that captures nothing until [`Self::restart`] is called.
|
||||||
|
///
|
||||||
|
/// It still follows the textures of the [`egui::Context`], so that it can render
|
||||||
|
/// correctly once it starts.
|
||||||
|
pub fn idle() -> Self {
|
||||||
|
Self {
|
||||||
|
options: RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE),
|
||||||
|
renderer: LazyRenderer::default(),
|
||||||
|
frames: Vec::new(),
|
||||||
|
pass_nr: 0,
|
||||||
|
active: false,
|
||||||
|
uploaded_font_atlas: false,
|
||||||
|
auto_save: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render with this renderer instead of the default `wgpu` one.
|
||||||
|
///
|
||||||
|
/// The renderer must be `Send + Sync`, because [`egui::Plugin`] requires it.
|
||||||
|
#[inline]
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_renderer(mut self, renderer: impl TestRenderer + Send + Sync + 'static) -> Self {
|
||||||
|
self.renderer = LazyRenderer::Ready(Box::new(renderer));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options this recording uses.
|
||||||
|
#[inline]
|
||||||
|
pub fn options(&self) -> &RecordingOptions {
|
||||||
|
&self.options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The options this recording uses, mutably.
|
||||||
|
#[inline]
|
||||||
|
pub fn options_mut(&mut self) -> &mut RecordingOptions {
|
||||||
|
&mut self.options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the plugin capturing frames?
|
||||||
|
#[inline]
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frames captured so far.
|
||||||
|
#[inline]
|
||||||
|
pub fn frames(&self) -> &[RgbaImage] {
|
||||||
|
&self.frames
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start capturing again, with new options. Any earlier frames are dropped.
|
||||||
|
pub fn restart(&mut self, options: RecordingOptions) {
|
||||||
|
self.options = options;
|
||||||
|
self.frames.clear();
|
||||||
|
self.pass_nr = 0;
|
||||||
|
self.active = true;
|
||||||
|
self.auto_save = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop capturing and drop all frames.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
self.frames.clear();
|
||||||
|
self.active = false;
|
||||||
|
self.auto_save = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the captured frames to disk.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if there are no frames, or if writing fails.
|
||||||
|
pub fn save(&self) -> Result<(), RecordingError> {
|
||||||
|
if self.frames.is_empty() {
|
||||||
|
return Err(RecordingError::NoFrames);
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.options.kind {
|
||||||
|
RecordKind::Gif { path, frame_rate } => save_gif(path, &self.frames, *frame_rate),
|
||||||
|
RecordKind::PngSequence { directory } => save_png_sequence(directory, &self.frames),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the recording will be written.
|
||||||
|
pub(crate) fn output_path(&self) -> &Path {
|
||||||
|
match &self.options.kind {
|
||||||
|
RecordKind::Gif { path, .. } => path,
|
||||||
|
RecordKind::PngSequence { directory } => directory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change where the recording will be written.
|
||||||
|
pub(crate) fn set_output_path(&mut self, new_path: PathBuf) {
|
||||||
|
match &mut self.options.kind {
|
||||||
|
RecordKind::Gif { path, .. } => *path = new_path,
|
||||||
|
RecordKind::PngSequence { directory } => *directory = new_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Should we capture this pass?
|
||||||
|
fn should_capture(&mut self) -> bool {
|
||||||
|
let pass_nr = self.pass_nr;
|
||||||
|
self.pass_nr = self.pass_nr.wrapping_add(1);
|
||||||
|
|
||||||
|
match self.options.trigger {
|
||||||
|
RecordingTrigger::ChangedFrames | RecordingTrigger::EveryFrame => true,
|
||||||
|
RecordingTrigger::EveryNthFrame(n) => pass_nr.is_multiple_of(n.max(1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a frame, dropping it if the trigger says it is a duplicate.
|
||||||
|
fn push_frame(&mut self, image: RgbaImage) {
|
||||||
|
if matches!(self.options.trigger, RecordingTrigger::ChangedFrames)
|
||||||
|
&& let Some(previous) = self.frames.last()
|
||||||
|
&& previous.as_raw() == image.as_raw()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.frames.push(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl egui::Plugin for RecordingPlugin {
|
||||||
|
fn debug_name(&self) -> &'static str {
|
||||||
|
"egui_kittest::RecordingPlugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) {
|
||||||
|
if !self.uploaded_font_atlas {
|
||||||
|
self.uploaded_font_atlas = true;
|
||||||
|
self.renderer.handle_delta(&mut font_atlas_delta(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our renderer needs the same textures as the renderer of the integration,
|
||||||
|
// so apply a copy of the deltas. Do this even while inactive, so that we can
|
||||||
|
// start recording at any time.
|
||||||
|
let mut textures_delta = output.textures_delta.clone();
|
||||||
|
self.renderer.handle_delta(&mut textures_delta);
|
||||||
|
|
||||||
|
if !self.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.platform_output.requested_discard() {
|
||||||
|
// This pass is thrown away and never shown, so don't record it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.should_capture() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `FullOutput` cannot be cloned without cloning the texture deltas
|
||||||
|
// (which panic if they are dropped unapplied), so build the render input by hand.
|
||||||
|
// Renderers only need the shapes.
|
||||||
|
let mut shapes = output.shapes.clone();
|
||||||
|
crate::push_cursor_shape(ctx, &mut shapes);
|
||||||
|
|
||||||
|
let render_output = FullOutput {
|
||||||
|
shapes,
|
||||||
|
pixels_per_point: output.pixels_per_point,
|
||||||
|
viewport_output: output.viewport_output.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.renderer.render(ctx, &render_output) {
|
||||||
|
Ok(image) => self.push_frame(image),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("egui_kittest recording: failed to render a frame: {err}");
|
||||||
|
if self.renderer.is_failed() {
|
||||||
|
// Nothing will ever render, so stop instead of complaining every pass.
|
||||||
|
self.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a recording that the harness started by itself is saved when the harness is dropped.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutoSaveMode {
|
||||||
|
/// Save only if the test failed. Written to `{output_path}/failures/{test_name}.gif`.
|
||||||
|
OnFailure,
|
||||||
|
|
||||||
|
/// Always save. Written to `{output_path}/recordings/{test_name}.gif`.
|
||||||
|
Always,
|
||||||
|
|
||||||
|
/// Always save to a temporary file, and show it in the default image viewer.
|
||||||
|
Open,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoSaveMode {
|
||||||
|
/// Where to write the recording of the test we are running.
|
||||||
|
fn path(self) -> PathBuf {
|
||||||
|
let name = std::thread::current()
|
||||||
|
.name()
|
||||||
|
.map_or_else(|| "recording".to_owned(), sanitize_file_name);
|
||||||
|
|
||||||
|
let subdirectory = match self {
|
||||||
|
Self::OnFailure => "failures",
|
||||||
|
Self::Always => "recordings",
|
||||||
|
Self::Open => {
|
||||||
|
if let Some(path) = temp_gif_path(&name) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
"recordings" // Fall back to a normal recording.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::config::config()
|
||||||
|
.output_path()
|
||||||
|
.join(subdirectory)
|
||||||
|
.join(format!("{name}.gif"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GIF in the temporary directory, which we keep after the test, so that the image
|
||||||
|
/// viewer can still read it.
|
||||||
|
fn temp_gif_path(name: &str) -> Option<PathBuf> {
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.disable_cleanup(true)
|
||||||
|
.prefix(&format!("kittest-recording-{name}-"))
|
||||||
|
.suffix(".gif")
|
||||||
|
.tempfile()
|
||||||
|
.inspect_err(|err| log::error!("egui_kittest: failed to create a temporary file: {err}"))
|
||||||
|
.ok()
|
||||||
|
.map(|file| file.path().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test threads are named after the test (e.g. `menu::tests::close_on_click`).
|
||||||
|
fn sanitize_file_name(name: &str) -> String {
|
||||||
|
name.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`RECORD_ENV_VAR`] asks for.
|
||||||
|
///
|
||||||
|
/// Read once, then cached, so that a test cannot change it halfway through a run.
|
||||||
|
pub(crate) fn record_env_var() -> Option<AutoSaveMode> {
|
||||||
|
static MODE: std::sync::OnceLock<Option<AutoSaveMode>> = std::sync::OnceLock::new();
|
||||||
|
*MODE.get_or_init(|| {
|
||||||
|
let value = std::env::var(RECORD_ENV_VAR).ok()?;
|
||||||
|
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"open" => Some(AutoSaveMode::Open),
|
||||||
|
"1" | "true" | "yes" | "on" => Some(AutoSaveMode::Always),
|
||||||
|
"" | "0" | "false" | "no" | "off" => None,
|
||||||
|
other => {
|
||||||
|
log::warn!("Ignoring {RECORD_ENV_VAR}={other:?}: expected `1` or `open`");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Harness integration
|
||||||
|
|
||||||
|
/// Frame rate of recordings that the harness starts by itself.
|
||||||
|
const AUTO_FRAME_RATE: f32 = 10.0;
|
||||||
|
|
||||||
|
/// A [`crate::Harness`] can record itself.
|
||||||
|
impl<State> crate::Harness<'_, State> {
|
||||||
|
/// Record the rest of this test session.
|
||||||
|
///
|
||||||
|
/// One frame is captured per egui pass, as configured by [`RecordingOptions::trigger`].
|
||||||
|
/// Call [`Self::finish_recording`] to write the result.
|
||||||
|
///
|
||||||
|
/// This registers a [`RecordingPlugin`] on the [`egui::Context`] of the harness, and
|
||||||
|
/// restarts the recording if there already is one.
|
||||||
|
///
|
||||||
|
/// The recording renders with its own renderer, which by default needs the `wgpu` feature.
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # use egui_kittest::{Harness, RecordingOptions};
|
||||||
|
/// let mut harness = Harness::new_ui(|ui| {
|
||||||
|
/// ui.label("Hello!");
|
||||||
|
/// });
|
||||||
|
/// harness.start_recording(RecordingOptions::gif("hello.gif", 10.0));
|
||||||
|
/// harness.run();
|
||||||
|
/// harness.finish_recording().unwrap();
|
||||||
|
/// ```
|
||||||
|
pub fn start_recording(&mut self, options: RecordingOptions) {
|
||||||
|
install(&self.ctx, options, None);
|
||||||
|
self.recording_auto_save = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the recording and write it to disk.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`RecordingError::NotRecording`] if nothing was being recorded,
|
||||||
|
/// [`RecordingError::NoFrames`] if no frame was captured,
|
||||||
|
/// or an I/O or encoding error if writing failed.
|
||||||
|
pub fn finish_recording(&mut self) -> Result<(), RecordingError> {
|
||||||
|
self.recording_auto_save = None;
|
||||||
|
|
||||||
|
let result = self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
|
if !plugin.is_active() {
|
||||||
|
return Err(RecordingError::NotRecording);
|
||||||
|
}
|
||||||
|
let result = plugin.save();
|
||||||
|
plugin.stop();
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
result.unwrap_or(Err(RecordingError::NotRecording))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the harness recording?
|
||||||
|
pub fn is_recording(&self) -> bool {
|
||||||
|
self.ctx
|
||||||
|
.with_plugin::<RecordingPlugin, _>(|plugin| plugin.is_active())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the [`RecordingPlugin`], e.g. to read the captured frames.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the harness never recorded anything.
|
||||||
|
pub fn with_recording<R>(&self, f: impl FnOnce(&mut RecordingPlugin) -> R) -> Option<R> {
|
||||||
|
self.ctx.with_plugin::<RecordingPlugin, _>(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start recording if the environment variable or the `kittest.toml` asks for it.
|
||||||
|
pub(crate) fn maybe_start_auto_recording(&mut self) {
|
||||||
|
let mode = if let Some(mode) = record_env_var() {
|
||||||
|
mode
|
||||||
|
} else if crate::config::config().save_gif_on_failure() {
|
||||||
|
AutoSaveMode::OnFailure
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The file name contains the test name, which we only look up when we save,
|
||||||
|
// so record to a placeholder path for now.
|
||||||
|
let options = RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE);
|
||||||
|
install(&self.ctx, options, Some(mode));
|
||||||
|
self.recording_auto_save = Some(AutoSaveOnDrop {
|
||||||
|
ctx: self.ctx.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an idle [`RecordingPlugin`], if there is none yet.
|
||||||
|
///
|
||||||
|
/// The harness does this before the first pass, so that the plugin sees every texture that
|
||||||
|
/// egui allocates, no matter when the recording starts.
|
||||||
|
pub(crate) fn install_idle(ctx: &Context) {
|
||||||
|
ctx.add_plugin(RecordingPlugin::idle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a [`RecordingPlugin`] on `ctx`, or restart the one that is already registered.
|
||||||
|
fn install(ctx: &Context, options: RecordingOptions, auto_save: Option<AutoSaveMode>) {
|
||||||
|
let restarted = ctx
|
||||||
|
.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
|
plugin.restart(options.clone());
|
||||||
|
plugin.auto_save = auto_save;
|
||||||
|
})
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !restarted {
|
||||||
|
let mut plugin = RecordingPlugin::new(options);
|
||||||
|
plugin.auto_save = auto_save;
|
||||||
|
ctx.add_plugin(plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a recording that the harness started by itself, when the harness is dropped.
|
||||||
|
pub(crate) struct AutoSaveOnDrop {
|
||||||
|
pub ctx: Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(clippy::print_stderr)] // We are (probably) in a panic, so logging may not be shown.
|
||||||
|
impl Drop for AutoSaveOnDrop {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
|
let Some(mode) = plugin.auto_save.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A failing test panics, either from an assert or from the snapshot results,
|
||||||
|
// which are dropped before this.
|
||||||
|
if mode == AutoSaveMode::OnFailure && !std::thread::panicking() {
|
||||||
|
plugin.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.set_output_path(mode.path());
|
||||||
|
let path = plugin.output_path().to_path_buf();
|
||||||
|
|
||||||
|
match plugin.save() {
|
||||||
|
Ok(()) => {
|
||||||
|
eprintln!("egui_kittest: saved a recording to {}", path.display());
|
||||||
|
|
||||||
|
if mode == AutoSaveMode::Open
|
||||||
|
&& let Err(err) = open::that_detached(&path)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"egui_kittest: failed to open {} in the default image viewer: {err}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RecordingError::NoFrames) => {}
|
||||||
|
Err(err) => eprintln!("egui_kittest: failed to save the recording: {err}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Renderer
|
||||||
|
|
||||||
|
/// A [`TestRenderer`] that is created when it is first used.
|
||||||
|
///
|
||||||
|
/// This mirrors [`crate::LazyRenderer`], but is `Send + Sync`, as [`egui::Plugin`] requires.
|
||||||
|
enum LazyRenderer {
|
||||||
|
Uninitialized {
|
||||||
|
textures_delta: TexturesDelta,
|
||||||
|
},
|
||||||
|
Ready(Box<dyn TestRenderer + Send + Sync>),
|
||||||
|
|
||||||
|
/// We failed to create a renderer, and already told the user about it.
|
||||||
|
#[cfg_attr(feature = "wgpu", expect(dead_code))]
|
||||||
|
// Only reachable without the `wgpu` feature.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LazyRenderer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Uninitialized {
|
||||||
|
textures_delta: TexturesDelta::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delta that sets the whole font atlas, as it looks right now.
|
||||||
|
fn font_atlas_delta(ctx: &Context) -> TexturesDelta {
|
||||||
|
let image = ctx.fonts(|fonts| fonts.image());
|
||||||
|
|
||||||
|
let mut delta = TexturesDelta::default();
|
||||||
|
delta.push(
|
||||||
|
egui::TextureId::default(), // The font atlas is always the first texture.
|
||||||
|
egui::epaint::ImageDelta::full(image, egui::TextureOptions::default()),
|
||||||
|
);
|
||||||
|
delta
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LazyRenderer {
|
||||||
|
fn handle_delta(&mut self, delta: &mut TexturesDelta) {
|
||||||
|
match self {
|
||||||
|
Self::Uninitialized { textures_delta } => textures_delta.append(std::mem::take(delta)),
|
||||||
|
Self::Ready(renderer) => renderer.handle_delta(delta),
|
||||||
|
Self::Failed => delta.clear(), // Don't panic when the delta is dropped.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, ctx: &Context, output: &FullOutput) -> Result<RgbaImage, String> {
|
||||||
|
if let Self::Uninitialized { textures_delta } = self {
|
||||||
|
#[cfg(feature = "wgpu")]
|
||||||
|
{
|
||||||
|
let mut renderer = crate::wgpu::WgpuTestRenderer::new();
|
||||||
|
renderer.handle_delta(textures_delta);
|
||||||
|
*self = Self::Ready(Box::new(renderer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "wgpu"))]
|
||||||
|
{
|
||||||
|
textures_delta.clear(); // Don't panic when the deltas are dropped.
|
||||||
|
*self = Self::Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Self::Ready(renderer) => renderer.render(ctx, output),
|
||||||
|
Self::Uninitialized { .. } | Self::Failed => Err("A recording needs a renderer. \
|
||||||
|
Enable the `wgpu` feature, or pass one to `RecordingPlugin::with_renderer`."
|
||||||
|
.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Will this renderer never render anything?
|
||||||
|
fn is_failed(&self) -> bool {
|
||||||
|
matches!(self, Self::Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LazyRenderer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Self::Uninitialized { textures_delta } = self {
|
||||||
|
textures_delta.clear(); // Don't panic when dropping unapplied deltas.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Saving
|
||||||
|
|
||||||
|
fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> {
|
||||||
|
create_parent_dir(path)?;
|
||||||
|
|
||||||
|
let file = File::create(path).map_err(|err| RecordingError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
let mut encoder = GifEncoder::new(BufWriter::new(file));
|
||||||
|
encoder.set_repeat(Repeat::Infinite)?;
|
||||||
|
|
||||||
|
let fps = frame_rate.clamp(1.0, 100.0).round() as u32;
|
||||||
|
let frame_delay = image::Delay::from_numer_denom_ms(1000, fps);
|
||||||
|
// Hold the last frame for a second, so it is obvious where the loop restarts.
|
||||||
|
let last_delay = image::Delay::from_numer_denom_ms(1000, 1);
|
||||||
|
|
||||||
|
// All frames of a GIF share one canvas, so grow the smaller ones to fit.
|
||||||
|
let size = max_size(frames);
|
||||||
|
|
||||||
|
let last_index = frames.len() - 1;
|
||||||
|
for (i, frame) in frames.iter().enumerate() {
|
||||||
|
let delay = if i == last_index {
|
||||||
|
last_delay
|
||||||
|
} else {
|
||||||
|
frame_delay
|
||||||
|
};
|
||||||
|
let image = pad_to(frame, size);
|
||||||
|
encoder.encode_frame(image::Frame::from_parts(image, 0, 0, delay))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<(), RecordingError> {
|
||||||
|
std::fs::create_dir_all(directory).map_err(|err| RecordingError::Io {
|
||||||
|
path: directory.to_path_buf(),
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for (i, frame) in frames.iter().enumerate() {
|
||||||
|
let path = directory.join(format!("frame_{i:04}.png"));
|
||||||
|
frame.save(&path).map_err(|err| match err {
|
||||||
|
image::ImageError::IoError(err) => RecordingError::Io { path, err },
|
||||||
|
err => RecordingError::Encode(err),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_parent_dir(path: &Path) -> Result<(), RecordingError> {
|
||||||
|
if let Some(parent) = path.parent()
|
||||||
|
&& !parent.as_os_str().is_empty()
|
||||||
|
{
|
||||||
|
std::fs::create_dir_all(parent).map_err(|err| RecordingError::Io {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of the largest frame, per axis.
|
||||||
|
fn max_size(frames: &[RgbaImage]) -> (u32, u32) {
|
||||||
|
frames.iter().fold((1, 1), |(w, h), frame| {
|
||||||
|
(w.max(frame.width()), h.max(frame.height()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `image` into the top-left corner of a transparent image of the given size.
|
||||||
|
fn pad_to(image: &RgbaImage, (width, height): (u32, u32)) -> RgbaImage {
|
||||||
|
if image.dimensions() == (width, height) {
|
||||||
|
return image.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut padded = RgbaImage::new(width, height);
|
||||||
|
for (x, y, pixel) in image.enumerate_pixels() {
|
||||||
|
padded.put_pixel(x, y, *pixel);
|
||||||
|
}
|
||||||
|
padded
|
||||||
|
}
|
||||||
181
crates/egui_kittest/tests/recording.rs
Normal file
181
crates/egui_kittest/tests/recording.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
#![cfg(all(feature = "recording", feature = "wgpu"))]
|
||||||
|
|
||||||
|
use egui_kittest::{Harness, RecordingOptions, RecordingPlugin, RecordingTrigger};
|
||||||
|
use kittest::Queryable as _;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn counter_harness(value: &mut u32) -> Harness<'_, &mut u32> {
|
||||||
|
Harness::builder()
|
||||||
|
.with_size(egui::Vec2::new(120.0, 60.0))
|
||||||
|
.build_ui_state(
|
||||||
|
|ui, state| {
|
||||||
|
if ui.button(format!("count: {state}")).clicked() {
|
||||||
|
**state += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_pngs(dir: &std::path::Path) -> usize {
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.expect("png output dir exists")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "png"))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_a_gif() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let gif_path = dir.path().join("counter.gif");
|
||||||
|
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
harness.start_recording(RecordingOptions::gif(&gif_path, 12.0));
|
||||||
|
|
||||||
|
harness.run();
|
||||||
|
harness.get_by_label_contains("count").click();
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
assert!(harness.is_recording());
|
||||||
|
harness.finish_recording().expect("save gif");
|
||||||
|
assert!(!harness.is_recording());
|
||||||
|
|
||||||
|
let frames = decode_gif_frames(&gif_path);
|
||||||
|
assert!(
|
||||||
|
frames >= 2,
|
||||||
|
"the click should have produced at least two different frames, got {frames}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_gif_frames(path: &std::path::Path) -> usize {
|
||||||
|
use image::AnimationDecoder as _;
|
||||||
|
|
||||||
|
let file = std::io::BufReader::new(std::fs::File::open(path).expect("gif exists"));
|
||||||
|
image::codecs::gif::GifDecoder::new(file)
|
||||||
|
.expect("decode gif")
|
||||||
|
.into_frames()
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_a_png_sequence() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let out = dir.path().join("frames");
|
||||||
|
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
harness.start_recording(
|
||||||
|
RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::EveryFrame),
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.run();
|
||||||
|
harness.get_by_label_contains("count").click();
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
harness.finish_recording().expect("save png sequence");
|
||||||
|
|
||||||
|
assert!(count_pngs(&out) > 0, "expected at least one frame");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_frames_drops_identical_frames() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let out = dir.path().join("frames");
|
||||||
|
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
harness.start_recording(
|
||||||
|
RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::ChangedFrames),
|
||||||
|
);
|
||||||
|
|
||||||
|
for _ in 0..6 {
|
||||||
|
harness.run();
|
||||||
|
}
|
||||||
|
harness.finish_recording().expect("save png sequence");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count_pngs(&out),
|
||||||
|
1,
|
||||||
|
"nothing changed, so only the first frame should be kept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_nth_frame_skips_frames() {
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
|
||||||
|
harness.start_recording(
|
||||||
|
RecordingOptions::gif(std::path::PathBuf::new(), 10.0)
|
||||||
|
.with_trigger(RecordingTrigger::EveryNthFrame(2)),
|
||||||
|
);
|
||||||
|
harness.run_steps(4);
|
||||||
|
|
||||||
|
let frames = harness
|
||||||
|
.with_recording(|plugin| plugin.frames().len())
|
||||||
|
.expect("the plugin is registered");
|
||||||
|
assert_eq!(frames, 2, "every second of the 4 passes should be captured");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finishing_without_recording_is_an_error() {
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
|
||||||
|
let err = harness.finish_recording().expect_err("not recording");
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
egui_kittest::RecordingError::NotRecording | egui_kittest::RecordingError::NoFrames
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recording_without_frames_is_an_error() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
harness.start_recording(RecordingOptions::gif(dir.path().join("empty.gif"), 10.0));
|
||||||
|
|
||||||
|
let err = harness.finish_recording().expect_err("no frames");
|
||||||
|
assert!(matches!(err, egui_kittest::RecordingError::NoFrames));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorder is a plain [`egui::Plugin`]: it records any [`egui::Context`],
|
||||||
|
/// with no harness and no renderer of your own.
|
||||||
|
#[test]
|
||||||
|
fn records_a_plain_context() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let gif_path = dir.path().join("plain.gif");
|
||||||
|
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
ctx.add_plugin(RecordingPlugin::new(RecordingOptions::gif(&gif_path, 10.0)));
|
||||||
|
|
||||||
|
let input = egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::Pos2::ZERO,
|
||||||
|
egui::Vec2::new(120.0, 60.0),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for pass in 0..3 {
|
||||||
|
let output = ctx.run_ui(input.clone(), |ui| {
|
||||||
|
ui.label(format!("pass {pass}"));
|
||||||
|
});
|
||||||
|
// We have no renderer of our own; the plugin already rendered what it needed.
|
||||||
|
output.drop_without_applying_deltas();
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = ctx
|
||||||
|
.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
|
plugin.save().expect("save gif");
|
||||||
|
plugin.frames().len()
|
||||||
|
})
|
||||||
|
.expect("the plugin is registered");
|
||||||
|
|
||||||
|
assert_eq!(frames, 3, "each pass shows a different label");
|
||||||
|
assert!(gif_path.exists(), "the GIF should have been written");
|
||||||
|
}
|
||||||
61
crates/egui_kittest/tests/recording_env.rs
Normal file
61
crates/egui_kittest/tests/recording_env.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Checks that `KITTEST_RECORD` records every harness and writes the GIFs next to the
|
||||||
|
//! snapshots, in `recordings/{test_name}.gif`.
|
||||||
|
//!
|
||||||
|
//! This is a test binary of its own because it changes the environment and the working
|
||||||
|
//! directory of the whole process.
|
||||||
|
|
||||||
|
#![cfg(all(feature = "recording", feature = "wgpu"))]
|
||||||
|
#![expect(unsafe_code)] // To set the environment variable.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Run the process in a temporary directory, with recording turned on.
|
||||||
|
///
|
||||||
|
/// Both the environment variable and the `kittest.toml` are read once per process,
|
||||||
|
/// so this must happen before the first harness is built.
|
||||||
|
fn setup() -> &'static std::path::Path {
|
||||||
|
static SETUP: OnceLock<TempDir> = OnceLock::new();
|
||||||
|
|
||||||
|
SETUP
|
||||||
|
.get_or_init(|| {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
||||||
|
// Write the recordings into the temporary directory.
|
||||||
|
std::fs::write(dir.path().join("kittest.toml"), "output_path = \".\"\n")
|
||||||
|
.expect("write kittest.toml");
|
||||||
|
|
||||||
|
// SAFETY: the `OnceLock` runs this once, before any other thread reads the
|
||||||
|
// environment or the working directory.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_current_dir(dir.path()).expect("chdir to the tempdir");
|
||||||
|
std::env::set_var(egui_kittest::RECORD_ENV_VAR, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
dir
|
||||||
|
})
|
||||||
|
.path()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_var_records_every_harness() {
|
||||||
|
let dir = setup();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut harness = Harness::new_ui(|ui| {
|
||||||
|
ui.label("recorded by the environment variable");
|
||||||
|
});
|
||||||
|
harness.run();
|
||||||
|
// Dropping the harness saves the recording.
|
||||||
|
}
|
||||||
|
|
||||||
|
let gif = dir
|
||||||
|
.join("recordings")
|
||||||
|
.join("env_var_records_every_harness.gif");
|
||||||
|
let size = std::fs::metadata(&gif)
|
||||||
|
.unwrap_or_else(|err| panic!("{} should exist: {err}", gif.display()))
|
||||||
|
.len();
|
||||||
|
assert!(size > 0, "the GIF should not be empty");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user