blob: 0a6b55ba6724399482fa5826839b9e91216b2de9 [file]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
#![allow(rustdoc::private_intra_doc_links, reason = "not a public-facing crate")]
//! Proc-macros for testing `vello_cpu` and `vello_hybrid`.
// How much renderers are allowed to deviate from the reference images
// per color component. A value of 0 means that it must be an exact match, i.e.
// each pixel must have the exact same value as the reference pixel. A value of 1
// means that the absolute difference of each component of a pixel must not be higher
// than 1. For example, if the target pixel is (233, 43, 64, 100), then permissible
// values are (232, 43, 65, 101) or (233, 42, 64, 100), but not (231, 43, 64, 100).
const DEFAULT_CPU_U8_TOLERANCE: u8 = 0;
const DEFAULT_HYBRID_TOLERANCE: u8 = 1;
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, Ident, ItemFn, Token, parse_macro_input};
#[derive(Debug)]
enum Attribute {
/// A key-value-like argument.
KeyValue { key: Ident, expr: Expr },
/// A flag-like argument.
Flag(Ident),
}
impl Parse for Attribute {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let key = input.parse()?;
let is_flag = !input.peek(Token![=]);
if is_flag {
Ok(Self::Flag(key))
} else {
// Skip equality token.
let _: Token![=] = input.parse()?;
let expr = input.parse()?;
Ok(Self::KeyValue { key, expr })
}
}
}
#[derive(Debug)]
struct AttributeInput {
args: Punctuated<Attribute, Token![,]>,
}
impl Parse for AttributeInput {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
Ok(Self {
args: input.parse_terminated(Attribute::parse, Token![,])?,
})
}
}
struct Arguments {
/// The width of the scene.
width: u16,
/// The height of the scene.
height: u16,
/// The (additional) maximum tolerance for how much two pixels are allowed to deviate from each other
/// when comparing to the reference images. Some renderers already have an existing tolerance
/// (see the constants at the top of the file), this value will simply be added
/// to the currently existing threshold. See the top of the file for an explanation of
/// how exactly the tolerance is interpreted.
cpu_tolerance: u8,
/// Same as above, but for the hybrid renderer.
hybrid_tolerance: u8,
/// Whether the background should be transparent (the default is white).
transparent: bool,
/// Whether the test should not be run on the CPU (`vello_cpu`).
skip_cpu: bool,
/// Whether the test should not be run on the GPU (`vello_hybrid`).
skip_hybrid: bool,
/// Whether no reference image should actually be created (for tests that only check
/// for panics, but are not interested in the actual output).
no_ref: bool,
/// A reason for ignoring a test.
ignore_reason: Option<String>,
}
impl Default for Arguments {
fn default() -> Self {
Self {
width: 100,
height: 100,
cpu_tolerance: 0,
hybrid_tolerance: 0,
transparent: false,
skip_cpu: false,
skip_hybrid: false,
no_ref: false,
ignore_reason: None,
}
}
}
/// Create a new Vello snapshot test.
/// See [`Arguments`] for documentation of the arguments, which are comma-separated.
/// Boolean flags are set on their own, and others are in the form of key-value pairs.
///
/// ## Example
/// ```ignore
/// use vello_dev_macros::vello_test;
/// use vello_api::kurbo::Rect;
/// use vello_api::color::palette::css::REBECCA_PURPLE;
///
/// #[vello_test(width = 10, height = 10)]
/// fn rectangle_above_viewport(ctx: &mut impl Renderer) {
/// let rect = Rect::new(2.0, -5.0, 8.0, 8.0);
///
/// ctx.set_paint(REBECCA_PURPLE.with_alpha(0.5));
/// ctx.fill_rect(&rect);
/// }
/// ```
#[proc_macro_attribute]
pub fn vello_test(attr: TokenStream, item: TokenStream) -> TokenStream {
let attrs = parse_macro_input!(attr as AttributeInput);
let input_fn = parse_macro_input!(item as ItemFn);
let input_fn_name = input_fn.sig.ident.clone();
let u8_fn_name = Ident::new(&format!("{}_cpu_u8", input_fn_name), input_fn_name.span());
let hybrid_fn_name = Ident::new(&format!("{}_hybrid", input_fn_name), input_fn_name.span());
// TODO: Tests with the same names in different modules can clash, see
// https://github.com/linebender/vello/pull/925#discussion_r2070710362.
// We should take the module path into consideration for naming the tests.
let input_fn_name_str = input_fn_name.to_string();
let u8_fn_name_str = u8_fn_name.to_string();
let hybrid_fn_name_str = hybrid_fn_name.to_string();
let Arguments {
width,
height,
mut cpu_tolerance,
mut hybrid_tolerance,
transparent,
skip_cpu,
mut skip_hybrid,
ignore_reason,
no_ref,
} = parse_args(&attrs);
// These tests currently don't work with `vello_hybrid`.
skip_hybrid |= {
input_fn_name_str.contains("compose")
|| input_fn_name_str.contains("gradient")
|| input_fn_name_str.contains("image")
|| input_fn_name_str.contains("layer")
|| input_fn_name_str.contains("mask")
|| input_fn_name_str.contains("mix")
|| input_fn_name_str.contains("opacity")
|| input_fn_name_str.contains("blurred_rounded_rect")
};
let empty_snippet = quote! {};
let ignore_snippet = if let Some(reason) = ignore_reason {
quote! {#[ignore = #reason]}
} else {
quote! {#[ignore]}
};
let ignore_hybrid = if skip_hybrid {
ignore_snippet.clone()
} else {
empty_snippet.clone()
};
let ignore_cpu = if skip_cpu {
ignore_snippet.clone()
} else {
empty_snippet.clone()
};
cpu_tolerance += DEFAULT_CPU_U8_TOLERANCE;
hybrid_tolerance += DEFAULT_HYBRID_TOLERANCE;
let expanded = quote! {
#input_fn
#ignore_cpu
#[test]
fn #u8_fn_name() {
use crate::util::{
check_ref, get_ctx
};
use vello_cpu::RenderContext;
let mut ctx = get_ctx::<RenderContext>(#width, #height, #transparent);
#input_fn_name(&mut ctx);
if !#no_ref {
check_ref(&ctx, #input_fn_name_str, #u8_fn_name_str, #cpu_tolerance, true);
}
}
#ignore_hybrid
#[test]
fn #hybrid_fn_name() {
use crate::util::{
check_ref, get_ctx
};
use vello_hybrid::Scene;
let mut ctx = get_ctx::<Scene>(#width, #height, #transparent);
#input_fn_name(&mut ctx);
if !#no_ref {
check_ref(&ctx, #input_fn_name_str, #hybrid_fn_name_str, #hybrid_tolerance, false);
}
}
};
expanded.into()
}
fn parse_args(attribute_input: &AttributeInput) -> Arguments {
let mut args = Arguments::default();
for arg in &attribute_input.args {
match arg {
Attribute::KeyValue { key, expr, .. } => {
let key_str = key.to_string();
match key_str.as_str() {
"ignore" => {
args.skip_cpu = true;
args.skip_hybrid = true;
args.ignore_reason = Some(parse_string_lit(expr, "ignore"));
}
"width" => args.width = parse_int_lit(expr, "width"),
"height" => args.height = parse_int_lit(expr, "height"),
"cpu_tolerance" => {
args.cpu_tolerance = parse_int_lit(expr, "cpu_tolerance")
.try_into()
.expect("value to fit for cpu_tolerance.");
}
"hybrid_tolerance" => {
args.hybrid_tolerance = parse_int_lit(expr, "hybrid_tolerance")
.try_into()
.expect("value to fit for hybrid_tolerance.");
}
_ => panic!("unknown pair attribute {}", key_str),
}
}
Attribute::Flag(flag_ident) => {
let flag_str = flag_ident.to_string();
match flag_str.as_str() {
"transparent" => args.transparent = true,
"skip_cpu" => args.skip_cpu = true,
"skip_hybrid" => args.skip_hybrid = true,
"no_ref" => args.no_ref = true,
"ignore" => {
args.skip_cpu = true;
args.skip_hybrid = true;
}
_ => panic!("unknown flag attribute {}", flag_str),
}
}
}
}
args
}
fn parse_int_lit(expr: &Expr, name: &str) -> u16 {
if let Expr::Lit(syn::ExprLit {
lit: syn::Lit::Int(lit_int),
..
}) = expr
{
lit_int.base10_parse::<u16>().unwrap()
} else {
panic!("invalid expression supplied to `{name}`")
}
}
fn parse_string_lit(expr: &Expr, name: &str) -> String {
if let Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = expr
{
lit_str.value()
} else {
panic!("invalid expression supplied to `{name}`")
}
}