use futures::executor::block_on;
use grafo_test_scenes::{build_main_scene, check_pixels, CANVAS_HEIGHT, CANVAS_WIDTH};
fn create_headless_renderer() -> Option<grafo::Renderer<'static>> {
create_headless_renderer_with_size_and_scale((CANVAS_WIDTH, CANVAS_HEIGHT), 1.0)
}
fn create_headless_renderer_with_size_and_scale(
physical_size: (u32, u32),
scale_factor: f64,
) -> Option<grafo::Renderer<'static>> {
match block_on(grafo::Renderer::try_new_headless(
physical_size,
scale_factor,
)) {
Ok(r) => Some(r),
Err(grafo::RendererCreationError::AdapterNotAvailable(_)) => {
println!("Skipping test: no suitable GPU adapter available.");
None
}
Err(e) => panic!("Failed to create headless renderer: {e}"),
}
}
fn assert_pixels_match(pixel_buffer: &[u8], expectations: &[grafo_test_scenes::PixelExpectation]) {
let failures = check_pixels(pixel_buffer, CANVAS_WIDTH, CANVAS_HEIGHT, expectations);
if !failures.is_empty() {
let message = format!(
"{} pixel expectation(s) failed:\n{}",
failures.len(),
failures.join("\n"),
);
panic!("{message}");
}
}
fn read_pixel_rgba(pixel_buffer: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
let stride = (width as usize) * 4;
let offset = (y as usize) * stride + (x as usize) * 4;
[
pixel_buffer[offset + 2],
pixel_buffer[offset + 1],
pixel_buffer[offset],
pixel_buffer[offset + 3],
]
}
#[test]
fn main_scene_pixel_expectations() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let expectations = build_main_scene(&mut renderer);
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let failures = check_pixels(&pixel_buffer, CANVAS_WIDTH, CANVAS_HEIGHT, &expectations);
if !failures.is_empty() {
let message = format!(
"{} pixel expectation(s) failed:\n{}",
failures.len(),
failures.join("\n"),
);
panic!("{message}");
}
}
#[test]
fn empty_draw_queue() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let bytes_per_pixel = 4;
let expected_length = (CANVAS_WIDTH as usize) * (CANVAS_HEIGHT as usize) * bytes_per_pixel;
assert_eq!(
pixel_buffer.len(),
expected_length,
"Pixel buffer length should equal width * height * {bytes_per_pixel}",
);
assert!(
pixel_buffer.iter().all(|&byte| byte == 0),
"Empty scene should produce a fully transparent (all-zero) buffer",
);
}
#[test]
fn single_root_no_children() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let shape = grafo::Shape::rect([(10.0, 10.0), (100.0, 100.0)], grafo::Stroke::default());
renderer
.add_shape(
shape,
None,
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(200, 50, 50)),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let expectations = vec![
grafo_test_scenes::PixelExpectation::opaque(55, 55, 200, 50, 50, "center_red"),
grafo_test_scenes::PixelExpectation::transparent(5, 5, "outside_rect"),
];
assert_pixels_match(&pixel_buffer, &expectations);
}
#[test]
fn original_size_texture_fit_uses_physical_pixels_on_hidpi() {
let physical_size = (200, 200);
let scale_factor = 2.0;
let Some(mut renderer) =
create_headless_renderer_with_size_and_scale(physical_size, scale_factor)
else {
return;
};
let green_texture_id = 9_001u64;
let green_texture_with_transparent_border_20x20 = (0..20u32)
.flat_map(|y| {
(0..20u32).flat_map(move |x| {
if x == 0 || x == 19 || y == 0 || y == 19 {
[0u8, 0u8, 0u8, 0u8]
} else {
[0u8, 255u8, 0u8, 255u8]
}
})
})
.collect::<Vec<_>>();
renderer.texture_manager().allocate_texture_with_data(
green_texture_id,
(20, 20),
&green_texture_with_transparent_border_20x20,
);
let shape = grafo::Shape::rect([(10.0, 10.0), (70.0, 70.0)], grafo::Stroke::default());
renderer
.add_shape(
shape,
None,
None,
grafo::ShapeDrawCommandOptions::new()
.background_texture(
grafo::ShapeTextureOptions::new(green_texture_id)
.fit_mode(grafo::ShapeTextureFitMode::OriginalSize),
)
.color(grafo::Color::WHITE),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let expectations = vec![
grafo_test_scenes::PixelExpectation::opaque(
30,
30,
0,
255,
0,
"inside_20px_physical_texture_region",
),
grafo_test_scenes::PixelExpectation::opaque(
60,
30,
255,
255,
255,
"outside_texture_region_inside_shape",
),
grafo_test_scenes::PixelExpectation::transparent(5, 5, "outside_shape"),
];
let failures = check_pixels(
&pixel_buffer,
physical_size.0,
physical_size.1,
&expectations,
);
if !failures.is_empty() {
let message = format!(
"{} pixel expectation(s) failed:\n{}",
failures.len(),
failures.join("\n"),
);
panic!("{message}");
}
}
#[test]
fn clipping_rect_clips_child_without_visible_surface() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let clip_rect_id = renderer
.add_clipping_rect(
[(20.0, 20.0), (80.0, 80.0)],
None,
None::<grafo::TransformInstance>,
true,
)
.unwrap();
let child = grafo::Shape::rect([(0.0, 0.0), (100.0, 100.0)], grafo::Stroke::default());
renderer
.add_shape(
child,
Some(clip_rect_id),
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(200, 50, 50)),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let expectations = vec![
grafo_test_scenes::PixelExpectation::opaque(50, 50, 200, 50, 50, "inside_clip_rect"),
grafo_test_scenes::PixelExpectation::transparent(10, 50, "left_of_clip_rect"),
grafo_test_scenes::PixelExpectation::transparent(50, 10, "above_clip_rect"),
grafo_test_scenes::PixelExpectation::transparent(90, 50, "right_of_clip_rect"),
grafo_test_scenes::PixelExpectation::transparent(50, 90, "below_clip_rect"),
];
assert_pixels_match(&pixel_buffer, &expectations);
}
#[test]
fn partially_offscreen_backdrop_capture_clears_reused_texture_space() {
let physical_size = (100, 80);
let Some(mut renderer) = create_headless_renderer_with_size_and_scale(physical_size, 1.0)
else {
return;
};
const AVERAGE_WITH_RIGHT_NEIGHBOR_EFFECT_ID: u64 = 9_101;
const AVERAGE_WITH_RIGHT_NEIGHBOR_WGSL: &str = r#"
const LOOKAHEAD_UV: vec2<f32> = vec2<f32>(0.4, 0.0);
@fragment
fn effect_main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let base = textureSample(t_input, s_input, uv);
let lookahead = textureSample(t_input, s_input, uv + LOOKAHEAD_UV);
return 0.5 * (base + lookahead);
}
"#;
renderer
.load_effect(
AVERAGE_WITH_RIGHT_NEIGHBOR_EFFECT_ID,
&[AVERAGE_WITH_RIGHT_NEIGHBOR_WGSL],
)
.expect("Failed to compile deterministic backdrop test effect");
let seeded_blue_panel =
grafo::Shape::rect([(20.0, 20.0), (60.0, 60.0)], grafo::Stroke::default());
renderer
.add_shape(
seeded_blue_panel.clone(),
None,
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(40, 40, 220)),
)
.unwrap();
let seeded_blue_panel_id = renderer
.add_shape(
seeded_blue_panel,
None,
None,
grafo::ShapeDrawCommandOptions::new(),
)
.unwrap();
renderer
.set_shape_backdrop_effect(
seeded_blue_panel_id,
AVERAGE_WITH_RIGHT_NEIGHBOR_EFFECT_ID,
&[],
grafo::BackdropEffectConfig::new().capture_area(
grafo::BackdropCaptureArea::ScreenRect([(20.0, 20.0), (60.0, 60.0)]),
),
)
.unwrap();
let mut seeded_frame: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut seeded_frame);
renderer.clear_draw_queue();
let visible_red_source =
grafo::Shape::rect([(70.0, 20.0), (100.0, 60.0)], grafo::Stroke::default());
renderer
.add_shape(
visible_red_source,
None,
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(220, 40, 40)),
)
.unwrap();
let partially_offscreen_panel =
grafo::Shape::rect([(70.0, 20.0), (100.0, 60.0)], grafo::Stroke::default());
let partially_offscreen_panel_id = renderer
.add_shape(
partially_offscreen_panel,
None,
None,
grafo::ShapeDrawCommandOptions::new(),
)
.unwrap();
renderer
.set_shape_backdrop_effect(
partially_offscreen_panel_id,
AVERAGE_WITH_RIGHT_NEIGHBOR_EFFECT_ID,
&[],
grafo::BackdropEffectConfig::new().capture_area(
grafo::BackdropCaptureArea::ScreenRect([(70.0, 20.0), (110.0, 60.0)]),
),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let sampled_pixel = read_pixel_rgba(&pixel_buffer, physical_size.0, 86, 40);
assert!(
sampled_pixel[0] > 80,
"expected visible red contribution after clearing untouched capture space, got {:?}",
sampled_pixel
);
assert!(
sampled_pixel[2] <= 50,
"expected offscreen capture space to stay transparent instead of leaking recycled blue, got {:?}",
sampled_pixel
);
assert!(
sampled_pixel[3] > 240,
"expected the cleared backdrop sample to composite back over the visible red source, got {:?}",
sampled_pixel
);
}
#[test]
fn standalone_clipping_rect_does_not_panic() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
renderer
.add_clipping_rect(
[(20.0, 20.0), (80.0, 80.0)],
None,
None::<grafo::TransformInstance>,
true,
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
assert!(
pixel_buffer.iter().all(|&byte| byte == 0),
"Standalone clipping rect should not draw any pixels",
);
}
#[test]
fn clipping_rect_rejects_non_axis_aligned_transform() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let clip_rect_id = renderer
.add_clipping_rect(
[(20.0, 20.0), (80.0, 80.0)],
None,
None::<grafo::TransformInstance>,
true,
)
.unwrap();
assert!(matches!(
renderer.add_clipping_rect(
[(20.0, 20.0), (80.0, 80.0)],
None,
Some(grafo::TransformInstance::rotation_z_deg(45.0)),
true,
),
Err(grafo::DrawCommandError::UnsupportedClipRectTransform)
));
let child = grafo::Shape::rect([(0.0, 0.0), (100.0, 100.0)], grafo::Stroke::default());
renderer
.add_shape(
child,
Some(clip_rect_id),
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(200, 50, 50)),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let expectations = vec![
grafo_test_scenes::PixelExpectation::opaque(
50,
50,
200,
50,
50,
"inside_unrotated_clip_rect",
),
grafo_test_scenes::PixelExpectation::transparent(10, 50, "outside_unrotated_clip_rect"),
];
assert_pixels_match(&pixel_buffer, &expectations);
}
#[test]
fn gradient_fill_basic() {
use grafo::*;
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let root = Shape::rect([(0.0, 0.0), (100.0, 100.0)], Stroke::default());
let root_id = renderer
.add_shape(
root,
None,
None,
ShapeDrawCommandOptions::new().color(Color::WHITE),
)
.unwrap();
let gradient = Gradient::linear(
LinearGradientDesc::new(
LinearGradientLine {
start: [10.0, 50.0],
end: [90.0, 50.0],
},
[
GradientStop::at_position(
GradientStopOffset::linear_radial(0.0),
Color::rgb(255, 0, 0),
),
GradientStop::at_position(
GradientStopOffset::linear_radial(1.0),
Color::rgb(0, 0, 255),
),
],
)
.with_interpolation(ColorInterpolation::Srgb),
)
.expect("valid gradient");
renderer
.add_shape(
Shape::rect([(10.0, 10.0), (90.0, 90.0)], Stroke::default()),
Some(root_id),
None,
ShapeDrawCommandOptions::new().fill(Fill::from(gradient)),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let w = CANVAS_WIDTH;
let center_x = 50u32;
let center_y = 50u32;
let offset = ((center_y * w + center_x) * 4) as usize;
let b = pixel_buffer[offset];
let g = pixel_buffer[offset + 1];
let r = pixel_buffer[offset + 2];
let a = pixel_buffer[offset + 3];
assert!(
!(r == 255 && g == 255 && b == 255),
"Center pixel should not be white (got rgba({r},{g},{b},{a})). Gradient is not rendering."
);
assert_eq!(a, 255, "Gradient pixel should be opaque");
}
#[test]
fn gradient_survives_pipeline_recreation() {
use grafo::*;
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let gradient = Gradient::linear(
LinearGradientDesc::new(
LinearGradientLine {
start: [10.0, 50.0],
end: [90.0, 50.0],
},
[
GradientStop::at_position(
GradientStopOffset::linear_radial(0.0),
Color::rgb(255, 0, 0),
),
GradientStop::at_position(
GradientStopOffset::linear_radial(1.0),
Color::rgb(0, 0, 255),
),
],
)
.with_interpolation(ColorInterpolation::Srgb),
)
.expect("valid gradient");
renderer
.add_shape(
Shape::rect([(10.0, 10.0), (90.0, 90.0)], Stroke::default()),
None,
None,
ShapeDrawCommandOptions::new().fill(Fill::from(gradient)),
)
.unwrap();
let mut buf = Vec::new();
renderer.render_to_buffer(&mut buf);
renderer.set_msaa_samples(4);
buf.clear();
renderer.render_to_buffer(&mut buf);
let w = CANVAS_WIDTH;
let cx = 50u32;
let cy = 50u32;
let off = ((cy * w + cx) * 4) as usize;
let (b, g, r, a) = (buf[off], buf[off + 1], buf[off + 2], buf[off + 3]);
assert_eq!(
a, 255,
"Gradient pixel should be opaque after pipeline recreation"
);
assert!(
!(r == 255 && g == 255 && b == 255),
"Gradient should not be white after pipeline recreation (got rgba({r},{g},{b},{a}))"
);
assert!(
r < 200 && b < 200,
"Center of red-to-blue gradient should be a purple-ish mix, got rgba({r},{g},{b},{a})"
);
}
#[test]
fn stencil_increment_gradient_does_not_leak_to_solid_parent() {
use grafo::*;
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let root = renderer
.add_shape(
Shape::rect(
[(0.0, 0.0), (CANVAS_WIDTH as f32, CANVAS_HEIGHT as f32)],
Stroke::default(),
),
None,
None,
ShapeDrawCommandOptions::new().color(Color::rgba(0, 0, 0, 0)),
)
.unwrap();
let radii = BorderRadii::new(8.0);
let gradient = Gradient::linear(
LinearGradientDesc::new(
LinearGradientLine {
start: [10.0, 50.0],
end: [140.0, 50.0],
},
[
GradientStop::at_position(
GradientStopOffset::linear_radial(0.0),
Color::rgb(255, 0, 0),
),
GradientStop::at_position(
GradientStopOffset::linear_radial(1.0),
Color::rgb(0, 0, 255),
),
],
)
.with_interpolation(ColorInterpolation::Srgb),
)
.expect("valid gradient");
let gradient_parent = renderer
.add_shape(
Shape::rounded_rect([(10.0, 10.0), (140.0, 90.0)], radii, Stroke::default()),
Some(root),
None,
ShapeDrawCommandOptions::new().fill(Fill::from(gradient)),
)
.unwrap();
renderer
.add_shape(
Shape::rect([(20.0, 20.0), (130.0, 80.0)], Stroke::default()),
Some(gradient_parent),
None,
ShapeDrawCommandOptions::new().color(Color::WHITE),
)
.unwrap();
let solid_parent = renderer
.add_shape(
Shape::rounded_rect([(160.0, 10.0), (290.0, 90.0)], radii, Stroke::default()),
Some(root),
None,
ShapeDrawCommandOptions::new().color(Color::rgb(0, 200, 0)),
)
.unwrap();
renderer
.add_shape(
Shape::rect([(170.0, 20.0), (280.0, 80.0)], Stroke::default()),
Some(solid_parent),
None,
ShapeDrawCommandOptions::new().color(Color::rgb(0, 200, 0)),
)
.unwrap();
let mut buf = Vec::new();
renderer.render_to_buffer(&mut buf);
let w = CANVAS_WIDTH;
let cx = 225u32; let cy = 50u32; let off = ((cy * w + cx) * 4) as usize;
let (b, g, r, a) = (buf[off], buf[off + 1], buf[off + 2], buf[off + 3]);
assert_eq!(a, 255, "Solid child should be opaque, got alpha={a}");
assert!(
g >= 180 && r < 40 && b < 40,
"Solid child should be green, got rgba({r},{g},{b},{a}). \
If this is reddish/bluish the gradient leaked from the previous StencilIncrement parent."
);
}
#[test]
fn multi_subpath_fill_has_no_internal_seam() {
let Some(mut renderer) = create_headless_renderer() else {
return;
};
let canvas_root = grafo::Shape::rect(
[(0.0, 0.0), (CANVAS_WIDTH as f32, CANVAS_HEIGHT as f32)],
grafo::Stroke::default(),
);
let canvas_root_id = renderer
.add_shape(
canvas_root,
None,
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::WHITE),
)
.unwrap();
let shape = grafo::Shape::builder()
.begin((10.0, 10.0))
.line_to((100.0, 10.0))
.line_to((100.0, 100.0))
.close()
.begin((10.0, 10.0))
.line_to((100.0, 100.0))
.line_to((10.0, 100.0))
.close()
.build();
renderer
.add_shape(
shape,
Some(canvas_root_id),
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(200, 50, 50)),
)
.unwrap();
let rect = grafo::Shape::rect([(140.0, 10.0), (230.0, 100.0)], grafo::Stroke::default());
renderer
.add_shape(
rect,
Some(canvas_root_id),
None,
grafo::ShapeDrawCommandOptions::new().color(grafo::Color::rgb(200, 50, 50)),
)
.unwrap();
let mut pixel_buffer: Vec<u8> = Vec::new();
renderer.render_to_buffer(&mut pixel_buffer);
let expectations = vec![
grafo_test_scenes::PixelExpectation::opaque(30, 30, 200, 50, 50, "diag_top_left"),
grafo_test_scenes::PixelExpectation::opaque(55, 55, 200, 50, 50, "diag_center"),
grafo_test_scenes::PixelExpectation::opaque(80, 80, 200, 50, 50, "diag_bottom_right"),
grafo_test_scenes::PixelExpectation::opaque(5, 5, 255, 255, 255, "outside_shape"),
grafo_test_scenes::PixelExpectation::opaque(185, 55, 200, 50, 50, "rect_center"),
grafo_test_scenes::PixelExpectation::opaque(145, 15, 200, 50, 50, "rect_near_corner"),
grafo_test_scenes::PixelExpectation::opaque(235, 55, 255, 255, 255, "outside_rect"),
];
assert_pixels_match(&pixel_buffer, &expectations);
}