vexy-vsvg-plugin-sdk 2.3.1

Plugin SDK for vexy-vsvg
Documentation
// this_file: crates/vexy-vsvg-plugin-sdk/src/property_tests.rs

//! Property-based testing for plugin correctness guarantees.
//!
//! This module uses `proptest` to generate random valid SVG documents and verify that
//! plugins uphold fundamental invariants:
//!
//! 1. **No crashes**: Plugins never panic on valid input
//! 2. **Valid output**: Plugin output remains parseable SVG
//! 3. **Idempotence**: Applying a plugin twice produces the same result as once
//!
//! # Architecture
//!
//! - **Strategies**: Functions that generate random SVG elements, attributes, and documents
//! - **Property tests**: Functions that take a plugin and verify it satisfies an invariant
//! - **Test runner**: `test_optimization_invariants()` runs all three checks
//!
//! # Usage
//!
//! ```ignore
//! use vexy_vsvg_plugin_sdk::property_tests::test_optimization_invariants;
//!
//! #[test]
//! fn test_my_plugin_properties() {
//!     let plugin = MyPlugin::new();
//!     test_optimization_invariants(plugin, 100).unwrap();
//! }
//! ```
//!
//! # Why Property Testing?
//!
//! Fixture tests validate known cases. Property tests explore the input space randomly,
//! catching edge cases we didn't anticipate. They're especially valuable for plugins that
//! manipulate paths, transforms, or perform complex structural changes.

use anyhow::Result;
use proptest::prelude::*;

use vexy_vsvg::Plugin;
use vexy_vsvg::{parse_svg, stringify};

/// Generates random SVG element names.
///
/// Covers common elements (shapes, containers, filters, gradients). Does not include
/// deprecated or rarely-used elements.
fn svg_tag_names() -> impl Strategy<Value = &'static str> {
    prop_oneof![
        Just("svg"),
        Just("g"),
        Just("rect"),
        Just("circle"),
        Just("ellipse"),
        Just("line"),
        Just("path"),
        Just("polygon"),
        Just("polyline"),
        Just("text"),
        Just("defs"),
        Just("use"),
        Just("image"),
        Just("clipPath"),
        Just("mask"),
        Just("pattern"),
        Just("marker"),
        Just("linearGradient"),
        Just("radialGradient"),
        Just("stop"),
        Just("filter"),
        Just("feGaussianBlur"),
        Just("feColorMatrix"),
        Just("feOffset"),
        Just("feMorphology"),
        Just("feFlood"),
        Just("feComposite"),
        Just("feMerge"),
        Just("feMergeNode"),
        Just("feImage"),
        Just("feTurbulence"),
        Just("feDisplacementMap"),
        Just("feConvolveMatrix"),
        Just("feDiffuseLighting"),
        Just("feSpecularLighting"),
        Just("feDistantLight"),
        Just("fePointLight"),
        Just("feSpotLight"),
        Just("style"),
        Just("title"),
        Just("desc"),
        Just("metadata"),
    ]
}

/// Generates random SVG attribute names.
///
/// Covers presentation attributes, geometric attributes, and common structural attributes.
fn attr_names() -> impl Strategy<Value = &'static str> {
    prop_oneof![
        Just("id"),
        Just("class"),
        Just("style"),
        Just("fill"),
        Just("stroke"),
        Just("stroke-width"),
        Just("stroke-dasharray"),
        Just("stroke-linecap"),
        Just("stroke-linejoin"),
        Just("opacity"),
        Just("fill-opacity"),
        Just("stroke-opacity"),
        Just("transform"),
        Just("x"),
        Just("y"),
        Just("width"),
        Just("height"),
        Just("cx"),
        Just("cy"),
        Just("r"),
        Just("rx"),
        Just("ry"),
        Just("x1"),
        Just("y1"),
        Just("x2"),
        Just("y2"),
        Just("d"),
        Just("points"),
        Just("href"),
        Just("xlink:href"),
        Just("viewBox"),
        Just("preserveAspectRatio"),
        Just("xmlns"),
        Just("version"),
        Just("baseProfile"),
    ]
}

/// Generates random attribute values.
///
/// Uses common patterns (colors, numbers, units, transforms) rather than arbitrary strings
/// to produce more realistic SVG.
fn attr_values() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("none".to_string()),
        Just("currentColor".to_string()),
        Just("#ff0000".to_string()),
        Just("rgb(255, 0, 0)".to_string()),
        Just("100".to_string()),
        Just("100px".to_string()),
        Just("50%".to_string()),
        Just("translate(10, 20)".to_string()),
        Just("scale(1.5)".to_string()),
        Just("rotate(45)".to_string()),
    ]
}

/// Generates a random self-closing SVG element with 0-5 attributes.
///
/// Returns a string like `<rect x="100" fill="red"/>`.
fn simple_element() -> impl Strategy<Value = String> {
    (
        svg_tag_names(),
        proptest::collection::vec((attr_names(), attr_values()), 0..5),
    )
        .prop_map(|(tag, attrs)| {
            if attrs.is_empty() {
                format!("<{}/>", tag)
            } else {
                let attr_str = attrs
                    .iter()
                    .map(|(name, value)| format!("{}=\"{}\"", name, value))
                    .collect::<Vec<_>>()
                    .join(" ");
                format!("<{} {}/>", tag, attr_str)
            }
        })
}

/// Generates a random complete SVG document with 1-10 elements.
///
/// Returns a string like:
/// ```xml
/// <svg xmlns="http://www.w3.org/2000/svg"><rect/><circle fill="red"/></svg>
/// ```
pub fn simple_svg_document() -> impl Strategy<Value = String> {
    proptest::collection::vec(simple_element(), 1..10).prop_map(|elements| {
        format!(
            "<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>",
            elements.join("")
        )
    })
}

/// Property: Plugin never panics on valid SVG input.
///
/// Generates random SVG documents, parses them, and verifies the plugin's `apply()` method
/// doesn't panic. Uses `Clone` to create fresh plugin instances for each test case.
pub fn prop_plugin_no_crash<P: Plugin + Clone>(plugin: P) -> impl Strategy<Value = ()> {
    simple_svg_document().prop_map(move |svg| {
        let plugin_clone = plugin.clone();
        if let Ok(mut doc) = parse_svg(&svg) {
            // Plugin should not panic
            let _ = plugin_clone.apply(&mut doc);
        }
    })
}

/// Property: Plugin output is always parseable SVG.
///
/// Verifies that after optimization, the stringified document can be parsed back into an
/// AST. This catches plugins that produce malformed markup.
pub fn prop_plugin_output_valid<P: Plugin + Clone>(plugin: P) -> impl Strategy<Value = ()> {
    simple_svg_document().prop_map(move |svg| {
        let plugin_clone = plugin.clone();
        if let Ok(mut doc) = parse_svg(&svg) {
            if plugin_clone.apply(&mut doc).is_ok() {
                // Output should be valid SVG (parseable)
                if let Ok(output) = stringify(&doc) {
                    assert!(parse_svg(&output).is_ok(), "Plugin output is not valid SVG");
                }
            }
        }
    })
}

/// Property: Plugin is idempotent (applying twice == applying once).
///
/// Optimization plugins should reach a fixed point: `f(f(x)) = f(x)`. This property catches
/// plugins that keep modifying the document on each pass, which would cause issues in
/// multi-pass optimization pipelines.
pub fn prop_plugin_idempotent<P: Plugin + Clone>(plugin: P) -> impl Strategy<Value = ()> {
    simple_svg_document().prop_map(move |svg| {
        let plugin1 = plugin.clone();
        let plugin2 = plugin.clone();

        if let Ok(mut doc1) = parse_svg(&svg) {
            if plugin1.apply(&mut doc1).is_ok() {
                if let Ok(output1) = stringify(&doc1) {
                    if let Ok(mut doc2) = parse_svg(&output1) {
                        if plugin2.apply(&mut doc2).is_ok() {
                            if let Ok(output2) = stringify(&doc2) {
                                assert_eq!(output1, output2, "Plugin is not idempotent");
                            }
                        }
                    }
                }
            }
        }
    })
}

/// Runs all three property tests for a plugin.
///
/// This is the main entry point for property testing. It verifies the plugin satisfies:
/// 1. No crashes
/// 2. Valid output
/// 3. Idempotence
///
/// # Arguments
///
/// * `plugin` - The plugin to test (must be `Clone` for fresh instances per test)
/// * `_test_cases` - Number of random test cases (currently unused, uses proptest defaults)
///
/// # Returns
///
/// `Ok(())` if all properties hold, `Err` if any property fails.
pub fn test_optimization_invariants<P: Plugin + Clone>(plugin: P, _test_cases: u32) -> Result<()> {
    let mut runner = proptest::test_runner::TestRunner::default();

    // Test 1: Plugin should not crash
    runner.run(&prop_plugin_no_crash(plugin.clone()), |_| Ok(()))?;

    // Test 2: Output should be valid SVG
    runner.run(&prop_plugin_output_valid(plugin.clone()), |_| Ok(()))?;

    // Test 3: Plugin should be idempotent
    runner.run(&prop_plugin_idempotent(plugin.clone()), |_| Ok(()))?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugins::RemoveCommentsPlugin;
    use proptest::strategy::ValueTree;

    #[test]
    fn test_simple_svg_generation() {
        let strategy = simple_svg_document();
        let mut runner = proptest::test_runner::TestRunner::default();

        for _ in 0..10 {
            let svg = strategy.new_tree(&mut runner).unwrap().current();
            assert!(svg.starts_with("<svg"));
            assert!(svg.ends_with("</svg>"));

            // Should be parseable
            assert!(parse_svg(&svg).is_ok());
        }
    }

    #[test]
    fn test_remove_comments_invariants() {
        let plugin = RemoveCommentsPlugin::new();
        test_optimization_invariants(plugin, 100).unwrap();
    }
}