mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 15:49:54 +00:00
395 lines
19 KiB
Rust
395 lines
19 KiB
Rust
//! Exact offscreen rendering comparisons against independent reference painters.
|
|
//!
|
|
//! These are paired same-renderer comparisons, not portable GPU golden files. The reference
|
|
//! paths deliberately do not call the shared components. Literal geometry and colors specify
|
|
//! the baseline; deliberate mutations prove color, spacing and stroke regressions are caught.
|
|
//! Set CRAFT_UI_VISUAL_DIR to retain bounded before/after/difference PNGs and comparison tables.
|
|
|
|
#[path = "visual/fixtures.rs"]
|
|
mod fixtures;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use egui::{Color32, Context, Event, FullOutput, Modifiers, PointerButton, Pos2, RawInput, Rect, Response, TextureId, Ui};
|
|
use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer};
|
|
use fixtures::{CANVAS, Case, Component, Mutation, State, Theme};
|
|
use image::{Rgba, RgbaImage};
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
struct Mesh {
|
|
clip: Rect,
|
|
texture: TextureId,
|
|
indices: Vec<u32>,
|
|
vertices: Vec<(Pos2, Pos2, Color32)>,
|
|
}
|
|
|
|
struct Capture {
|
|
image: RgbaImage,
|
|
meshes: Option<Vec<Mesh>>,
|
|
}
|
|
|
|
fn context(theme: Theme, scale: f32) -> Context {
|
|
let ctx = Context::default();
|
|
ctx.set_theme(theme.egui());
|
|
ctx.set_pixels_per_point(scale);
|
|
ctx.set_fonts(egui::FontDefinitions::empty());
|
|
ctx.all_styles_mut(|style| {
|
|
style.animation_time = 0.0;
|
|
style.visuals.text_cursor.blink = false;
|
|
style.visuals.disabled_alpha = 0.4;
|
|
});
|
|
ctx
|
|
}
|
|
|
|
fn raw_input(frame: usize, events: Vec<Event>) -> RawInput {
|
|
RawInput {
|
|
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, CANVAS)),
|
|
time: Some(frame as f64 / 60.0),
|
|
events,
|
|
max_texture_side: Some(2048),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn background(ui: &mut Ui, theme: Theme) {
|
|
let screen = Rect::from_min_size(Pos2::ZERO, CANVAS);
|
|
ui.set_clip_rect(screen);
|
|
ui.painter().rect_filled(screen, 0.0, theme.surface());
|
|
}
|
|
|
|
fn finish_capture(renderer: &mut WgpuTestRenderer, ctx: &Context, output: &FullOutput) -> Capture {
|
|
let meshes = ctx
|
|
.tessellate(output.shapes.clone(), output.pixels_per_point)
|
|
.into_iter()
|
|
.map(|clipped| {
|
|
let egui::epaint::Primitive::Mesh(mesh) = clipped.primitive else { panic!("visual fixtures must not contain paint callbacks") };
|
|
Mesh {
|
|
clip: clipped.clip_rect,
|
|
texture: mesh.texture_id,
|
|
indices: mesh.indices,
|
|
vertices: mesh.vertices.into_iter().map(|vertex| (vertex.pos, vertex.uv, vertex.color)).collect(),
|
|
}
|
|
})
|
|
.collect();
|
|
let image = renderer.render(ctx, output).expect("offscreen renderer is required for the visual suite");
|
|
assert!(image.width() > 0 && image.height() > 0 && image.width() <= 256 && image.height() <= 144);
|
|
Capture { image, meshes: Some(meshes) }
|
|
}
|
|
|
|
fn capture(renderer: &mut WgpuTestRenderer, case: Case, reference: bool, mutation: Mutation) -> Capture {
|
|
let ctx = context(case.theme, case.scale);
|
|
let mut output = FullOutput::default();
|
|
let mut responses: Vec<Response> = Vec::new();
|
|
for frame in 0..4 {
|
|
let mut events = Vec::new();
|
|
if !reference && frame == 2 {
|
|
if case.state == State::Focus {
|
|
ctx.memory_mut(|memory| memory.request_focus(responses[0].id));
|
|
}
|
|
if case.state.pointer() {
|
|
let center = case.control_rect().center();
|
|
events.push(Event::PointerMoved(center));
|
|
if case.state.pressed() {
|
|
events.push(Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
|
}
|
|
}
|
|
}
|
|
output = ctx.run_ui(raw_input(frame, events), |ui| {
|
|
background(ui, case.theme);
|
|
if reference {
|
|
fixtures::reference(ui, case);
|
|
} else {
|
|
responses = fixtures::actual(ui, case, mutation);
|
|
}
|
|
});
|
|
renderer.handle_delta(&mut output.textures_delta);
|
|
}
|
|
assert_eq!(output.pixels_per_point, case.scale);
|
|
if !reference {
|
|
let response = &responses[0];
|
|
assert_eq!(response.enabled(), case.state.enabled(), "{case:?}");
|
|
if case.state == State::Hover {
|
|
assert!(response.hovered(), "hover was not exercised: {case:?}");
|
|
}
|
|
if case.state.pressed() {
|
|
assert!(response.is_pointer_button_down_on(), "press was not exercised: {case:?}");
|
|
if case.component.is_split() {
|
|
assert!(response.dragged(), "divider drag was not exercised: {case:?}");
|
|
}
|
|
}
|
|
if case.state == State::Focus {
|
|
assert!(response.has_focus(), "focus was not exercised: {case:?}");
|
|
}
|
|
if case.state == State::Disabled {
|
|
assert!(!response.hovered() && !response.has_focus(), "disabled pointer input must remain inert: {case:?}");
|
|
}
|
|
}
|
|
finish_capture(renderer, &ctx, &output)
|
|
}
|
|
|
|
fn static_capture(renderer: &mut WgpuTestRenderer, theme: Theme, scale: f32, mut paint: impl FnMut(&mut Ui)) -> Capture {
|
|
let ctx = context(theme, scale);
|
|
let mut output = FullOutput::default();
|
|
for frame in 0..2 {
|
|
output = ctx.run_ui(raw_input(frame, vec![]), |ui| {
|
|
background(ui, theme);
|
|
paint(ui);
|
|
});
|
|
renderer.handle_delta(&mut output.textures_delta);
|
|
}
|
|
assert_eq!(output.pixels_per_point, scale);
|
|
finish_capture(renderer, &ctx, &output)
|
|
}
|
|
|
|
struct Artifacts {
|
|
directory: Option<PathBuf>,
|
|
rows: String,
|
|
}
|
|
|
|
impl Artifacts {
|
|
fn new(suite: &str) -> Self {
|
|
let directory = std::env::var_os("CRAFT_UI_VISUAL_DIR").map(|root| PathBuf::from(root).join(suite));
|
|
if let Some(directory) = &directory {
|
|
std::fs::create_dir_all(directory).unwrap();
|
|
}
|
|
Self { directory, rows: "case\twidth\theight\tchanged_pixels\tmesh_equal\texpected_change\n".into() }
|
|
}
|
|
|
|
fn record(&mut self, name: &str, before: &Capture, after: &Capture, expected_change: bool) -> usize {
|
|
assert_eq!(before.image.dimensions(), after.image.dimensions(), "{name}");
|
|
let mut changed = 0;
|
|
let mut difference = RgbaImage::new(before.image.width(), before.image.height());
|
|
for ((before, after), pixel) in before.image.pixels().zip(after.image.pixels()).zip(difference.pixels_mut()) {
|
|
if before != after {
|
|
changed += 1;
|
|
*pixel = Rgba([255, 0, 128, 255]);
|
|
}
|
|
}
|
|
let mesh_equal = match (&before.meshes, &after.meshes) {
|
|
(Some(before), Some(after)) => (before == after).to_string(),
|
|
(None, None) => "not_applicable".into(),
|
|
_ => panic!("both captures must have the same comparison scope"),
|
|
};
|
|
self.rows.push_str(&format!("{name}\t{}\t{}\t{changed}\t{mesh_equal}\t{expected_change}\n", before.image.width(), before.image.height()));
|
|
if let Some(directory) = &self.directory {
|
|
before.image.save(directory.join(format!("{name}-before.png"))).unwrap();
|
|
after.image.save(directory.join(format!("{name}-after.png"))).unwrap();
|
|
difference.save(directory.join(format!("{name}-difference.png"))).unwrap();
|
|
// Preserve completed rows even if a later assertion fails.
|
|
std::fs::write(directory.join("comparison.tsv"), &self.rows).unwrap();
|
|
}
|
|
changed
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rendered_controls_match_independent_reference_pixels_and_meshes() {
|
|
let mut renderer = WgpuTestRenderer::new();
|
|
let mut artifacts = Artifacts::new("controls");
|
|
let mut count = 0;
|
|
for component in Component::ALL {
|
|
for state in State::ALL {
|
|
if component.is_split() && state.selected() {
|
|
continue; // A resize handle has no selected state in its public contract.
|
|
}
|
|
for theme in [Theme::Light, Theme::Dark] {
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
for compact in [false, true] {
|
|
let case = Case { component, state, theme, scale, compact };
|
|
let before = capture(&mut renderer, case, true, Mutation::None);
|
|
let after = capture(&mut renderer, case, false, Mutation::None);
|
|
let changed = artifacts.record(&case.name(), &before, &after, false);
|
|
assert_eq!(before.meshes, after.meshes, "reference mesh mismatch: {case:?}");
|
|
assert_eq!(changed, 0, "reference pixels changed: {case:?}");
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(count, 372);
|
|
eprintln!("{count} control before/after pairs matched exact RGBA pixels and tessellated meshes");
|
|
}
|
|
|
|
#[test]
|
|
fn visual_comparisons_detect_color_spacing_and_stroke_mutations() {
|
|
let mut renderer = WgpuTestRenderer::new();
|
|
let mut artifacts = Artifacts::new("negative-controls");
|
|
let mut count = 0;
|
|
for theme in [Theme::Light, Theme::Dark] {
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
for compact in [false, true] {
|
|
let case = Case { component: Component::Button, state: State::Normal, theme, scale, compact };
|
|
let before = capture(&mut renderer, case, true, Mutation::None);
|
|
for mutation in [Mutation::Color, Mutation::Spacing, Mutation::Stroke] {
|
|
let after = capture(&mut renderer, case, false, mutation);
|
|
let changed = artifacts.record(&format!("{}-{mutation:?}", case.name()), &before, &after, true);
|
|
assert_ne!(before.meshes, after.meshes, "mesh comparison missed {mutation:?}: {case:?}");
|
|
assert!(changed > 0, "pixel comparison missed {mutation:?}: {case:?}");
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(count, 36);
|
|
eprintln!("{count} deliberate color/spacing/stroke changes were detected in both pixels and meshes");
|
|
}
|
|
|
|
#[test]
|
|
fn toolbar_fitting_matches_literal_group_and_overflow_positions() {
|
|
use craft_ui::buttons::IconButton;
|
|
use craft_ui::layout::fit_toolbar;
|
|
use egui::{Align, Layout, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
|
|
|
let mut renderer = WgpuTestRenderer::new();
|
|
let mut artifacts = Artifacts::new("toolbar");
|
|
let mut count = 0;
|
|
// Independently specified rectangles: three natural groups, one group + More, More only.
|
|
for (available, expected) in
|
|
[(112.0, vec![(12.0, 24.0), (40.0, 28.0), (72.0, 36.0)]), (64.0, vec![(12.0, 24.0), (40.0, 20.0)]), (20.0, vec![(12.0, 20.0)])]
|
|
{
|
|
for theme in [Theme::Light, Theme::Dark] {
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
let p = theme.palette();
|
|
let before = static_capture(&mut renderer, theme, scale, |ui| {
|
|
for &(x, width) in &expected {
|
|
let rect = Rect::from_min_size(pos2(x, 12.0), vec2(width, 24.0));
|
|
ui.painter().rect_filled(rect, 3.0, p.normal);
|
|
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, p.border), StrokeKind::Inside);
|
|
fixtures::icon(ui.painter(), rect, p.icon, true);
|
|
}
|
|
});
|
|
let after = static_capture(&mut renderer, theme, scale, |ui| {
|
|
let fit = fit_toolbar(&[24.0, 28.0, 36.0], available, 4.0, 20.0).unwrap();
|
|
let style = fixtures::button_style(theme);
|
|
ui.scope_builder(
|
|
UiBuilder::new()
|
|
.max_rect(Rect::from_min_size(pos2(12.0, 12.0), vec2(available, 24.0)))
|
|
.layout(Layout::left_to_right(Align::Min)),
|
|
|ui| {
|
|
ui.spacing_mut().item_spacing.x = 4.0;
|
|
for &(_, width) in &fit.shown {
|
|
IconButton::new("Group", vec2(width, 24.0), &style)
|
|
.show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true));
|
|
}
|
|
if !fit.overflow.is_empty() {
|
|
IconButton::new("More", vec2(fit.overflow_width, 24.0), &style)
|
|
.show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true));
|
|
}
|
|
},
|
|
);
|
|
});
|
|
let name = format!("toolbar-{available}-{}-{scale}x", theme.name());
|
|
let changed = artifacts.record(&name, &before, &after, false);
|
|
assert_eq!(before.meshes, after.meshes, "{name}");
|
|
assert_eq!(changed, 0, "{name}");
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(count, 18);
|
|
}
|
|
|
|
#[test]
|
|
fn widget_palette_renders_all_five_states_with_existing_borders_and_expansion() {
|
|
use craft_ui::theme::WidgetPalette;
|
|
use egui::{CornerRadius, Stroke, StrokeKind, pos2, vec2};
|
|
|
|
let mut renderer = WgpuTestRenderer::new();
|
|
let mut artifacts = Artifacts::new("widget-palette");
|
|
for theme in [Theme::Light, Theme::Dark] {
|
|
for scale in [1.0, 1.5, 2.0] {
|
|
for compact in [false, true] {
|
|
let p = theme.palette();
|
|
let fills = [p.normal, p.hovered, p.pressed, p.selected, theme.surface()];
|
|
let rect = |index| {
|
|
Rect::from_min_size(
|
|
pos2(12.0 + (index % 3) as f32 * 34.0, 12.0 + (index / 3) as f32 * 26.0),
|
|
if compact { vec2(20.0, 12.0) } else { vec2(26.0, 18.0) },
|
|
)
|
|
};
|
|
let borders = [Color32::RED, Color32::GREEN, Color32::BLUE, p.border, p.focus];
|
|
let before = static_capture(&mut renderer, theme, scale, |ui| {
|
|
for index in 0..5 {
|
|
let rect = rect(index).expand(if index % 2 == 0 { 0.0 } else { 0.5 });
|
|
ui.painter().rect_filled(rect, 2.0, fills[index]);
|
|
// A separate inset uses the weak fill, so omission of either fill is visible.
|
|
ui.painter().rect_filled(rect.shrink(3.0), 0.0, fills[index]);
|
|
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, borders[index]), StrokeKind::Inside);
|
|
ui.painter()
|
|
.line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], Stroke::new(1.5, p.icon));
|
|
}
|
|
});
|
|
let after = static_capture(&mut renderer, theme, scale, |ui| {
|
|
let mut widgets = ui.visuals().widgets.clone();
|
|
for (index, widget) in
|
|
[&mut widgets.noninteractive, &mut widgets.inactive, &mut widgets.hovered, &mut widgets.active, &mut widgets.open]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
widget.bg_stroke = Stroke::new(1.0, borders[index]);
|
|
widget.expansion = if index % 2 == 0 { 0.0 } else { 0.5 };
|
|
}
|
|
WidgetPalette { noninteractive: p.normal, inactive: p.hovered, hovered: p.pressed, active: p.selected, open: theme.surface() }
|
|
.apply_to(&mut widgets, CornerRadius::same(2), Stroke::new(1.5, p.icon));
|
|
for (index, widget) in
|
|
[widgets.noninteractive, widgets.inactive, widgets.hovered, widgets.active, widgets.open].iter().enumerate()
|
|
{
|
|
let rect = rect(index).expand(widget.expansion);
|
|
ui.painter().rect_filled(rect, widget.corner_radius, widget.bg_fill);
|
|
ui.painter().rect_filled(rect.shrink(3.0), 0.0, widget.weak_bg_fill);
|
|
ui.painter().rect_stroke(rect, widget.corner_radius, widget.bg_stroke, StrokeKind::Inside);
|
|
ui.painter().line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], widget.fg_stroke);
|
|
}
|
|
});
|
|
let name = format!("palette-{}-{scale}x-{}", theme.name(), if compact { "compact" } else { "normal" });
|
|
let changed = artifacts.record(&name, &before, &after, false);
|
|
assert_eq!(before.meshes, after.meshes, "{name}");
|
|
assert_eq!(changed, 0, "{name}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn svg_preparation_matches_literal_raster_references_at_all_scales() {
|
|
use craft_ui::icons::{SvgIconSet, SvgStyle};
|
|
use egui::{
|
|
ImageSource,
|
|
load::{Bytes, ImagePoll, SizeHint},
|
|
};
|
|
|
|
const SOURCE: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"#;
|
|
const LIGHT: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0f172a" stroke-width="1.75"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"##;
|
|
const DARK: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e2e8f0" stroke-width="1.75"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"##;
|
|
fn raster(ctx: &Context, uri: &str, size: u32) -> Capture {
|
|
let ImagePoll::Ready { image } = ctx.try_load_image(uri, SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true }).unwrap()
|
|
else {
|
|
panic!("compiled-in SVG must rasterize synchronously")
|
|
};
|
|
assert_eq!(image.size, [size as usize; 2]);
|
|
let image = RgbaImage::from_raw(size, size, image.pixels.iter().flat_map(|pixel| pixel.to_srgba_unmultiplied()).collect()).unwrap();
|
|
assert!(image.pixels().any(|pixel| pixel[3] != 0), "blank SVG is not a valid matching baseline");
|
|
Capture { image, meshes: None }
|
|
}
|
|
let mut artifacts = Artifacts::new("svg");
|
|
for (theme, color, literal) in [(Theme::Light, "#0f172a", LIGHT), (Theme::Dark, "#e2e8f0", DARK)] {
|
|
for (scale, numerator) in [(1.0, 2), (1.5, 3), (2.0, 4)] {
|
|
for logical_size in [12, 24] {
|
|
let ctx = Context::default();
|
|
egui_extras::install_image_loaders(&ctx);
|
|
ctx.include_bytes("bytes://literal-reference.svg", Bytes::Static(literal));
|
|
let icons =
|
|
SvgIconSet::new(&[("box", SOURCE)], SvgStyle { current_color: color, stroke_width: Some("1.75") }, "bytes://actual/", "box");
|
|
let ImageSource::Bytes { uri, bytes } = icons.source("box") else { panic!("catalog must return embedded bytes") };
|
|
ctx.include_bytes(uri.clone(), bytes);
|
|
let size = logical_size * numerator / 2;
|
|
let before = raster(&ctx, "bytes://literal-reference.svg", size);
|
|
let after = raster(&ctx, &uri, size);
|
|
let name = format!("svg-{}-{scale}x-{logical_size}pt", theme.name());
|
|
assert_eq!(artifacts.record(&name, &before, &after, false), 0, "{name}");
|
|
}
|
|
}
|
|
}
|
|
}
|