Files
wifi-densepose/examples/scipix/docs/11_TEST_STRATEGY.md
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

70 KiB
Raw Blame History

Comprehensive Testing Strategy for Ruvector-Scipix OCR System

Overview

This document defines a comprehensive testing strategy for the ruvector-scipix OCR system, covering unit testing, integration testing, accuracy validation, performance benchmarking, regression testing, fuzz testing, and CI/CD integration.

Table of Contents

  1. Unit Testing
  2. Integration Testing
  3. Accuracy Testing
  4. Performance Testing
  5. Regression Testing
  6. Fuzz Testing
  7. Test Data Management
  8. CI/CD Integration
  9. Property-Based Testing
  10. Test Coverage Requirements

1. Unit Testing

Unit tests verify individual components in isolation, ensuring each function works correctly.

1.1 Image Preprocessing Functions

Test image loading, enhancement, and geometric corrections.

// tests/unit/preprocessing_tests.rs
use ruvector_scipix::preprocessing::{
    ImageLoader, ImageEnhancer, Binarizer, RotationDetector, Deskewer
};
use image::{GrayImage, ImageBuffer, Luma};

#[test]
fn test_image_loader_valid_formats() {
    let loader = ImageLoader::new();

    // Test loading different formats
    let formats = vec!["png", "jpg", "tiff", "webp"];
    for format in formats {
        let path = format!("testdata/sample.{}", format);
        let result = loader.load(&path);
        assert!(result.is_ok(), "Failed to load {} format", format);
    }
}

#[test]
fn test_image_loader_dimension_limits() {
    let loader = ImageLoader::new();

    // Test dimension validation
    let oversized_path = "testdata/oversized_16384x16384.png";
    let result = loader.load(oversized_path);
    assert!(result.is_err(), "Should reject oversized images");
}

#[test]
fn test_image_loader_invalid_file() {
    let loader = ImageLoader::new();

    let result = loader.load("testdata/nonexistent.png");
    assert!(result.is_err(), "Should fail on nonexistent file");

    let result = loader.load("testdata/corrupted.png");
    assert!(result.is_err(), "Should fail on corrupted file");
}

#[test]
fn test_clahe_enhancement() {
    let enhancer = ImageEnhancer::new();

    // Create test image with varying contrast
    let img = create_low_contrast_image(256, 256);
    let enhanced = enhancer.apply_clahe(&img);

    // Verify contrast improvement
    let original_contrast = calculate_contrast(&img);
    let enhanced_contrast = calculate_contrast(&enhanced);

    assert!(enhanced_contrast > original_contrast,
        "CLAHE should increase contrast");
}

#[test]
fn test_otsu_binarization() {
    let binarizer = Binarizer;

    // Create grayscale image with known threshold
    let img = create_bimodal_image(256, 256);
    let binary = binarizer.otsu_binarize(&img);

    // Verify output is binary (only 0 and 255)
    for pixel in binary.pixels() {
        assert!(pixel[0] == 0 || pixel[0] == 255,
            "Binary image should only have 0 or 255 values");
    }
}

#[test]
fn test_rotation_detection_accuracy() {
    let detector = RotationDetector;

    // Test known rotation angles
    let test_angles = vec![0.0, 15.0, 30.0, 45.0, 90.0, 180.0];

    for angle in test_angles {
        let img = load_test_image("testdata/text_sample.png");
        let rotated = detector.rotate_image(&img, angle);
        let detected_angle = detector.detect_rotation_angle(&rotated);

        // Allow 2-degree tolerance
        assert!(
            (detected_angle - angle).abs() < 2.0,
            "Detected angle {} should be close to {}",
            detected_angle, angle
        );
    }
}

#[test]
fn test_deskewing() {
    let deskewer = Deskewer;

    // Create skewed image
    let img = load_test_image("testdata/skewed_text.png");
    let (deskewed, angle) = deskewer.deskew(&img);

    // Verify skew angle is reasonable
    assert!(angle.abs() < 45.0, "Skew angle should be within ±45°");

    // Verify deskewed image dimensions are valid
    assert!(deskewed.width() > 0 && deskewed.height() > 0);
}

// Helper functions
fn create_low_contrast_image(width: u32, height: u32) -> GrayImage {
    ImageBuffer::from_fn(width, height, |x, y| {
        let val = (x % 50 + 100) as u8;
        Luma([val])
    })
}

fn create_bimodal_image(width: u32, height: u32) -> GrayImage {
    ImageBuffer::from_fn(width, height, |x, y| {
        let val = if (x + y) % 2 == 0 { 50 } else { 200 };
        Luma([val])
    })
}

fn calculate_contrast(img: &GrayImage) -> f64 {
    let pixels: Vec<u8> = img.pixels().map(|p| p[0]).collect();
    let mean = pixels.iter().map(|&x| x as f64).sum::<f64>() / pixels.len() as f64;
    let variance = pixels.iter()
        .map(|&x| (x as f64 - mean).powi(2))
        .sum::<f64>() / pixels.len() as f64;
    variance.sqrt()
}

fn load_test_image(path: &str) -> GrayImage {
    image::open(path).unwrap().to_luma8()
}

1.2 LaTeX Token Parsing

Test LaTeX parsing and tokenization logic.

// tests/unit/latex_parser_tests.rs
use ruvector_scipix::latex::{LatexParser, LatexToken, TokenType};

#[test]
fn test_simple_expression_parsing() {
    let parser = LatexParser::new();

    let input = "x^2 + 2x + 1";
    let tokens = parser.parse(input).unwrap();

    assert_eq!(tokens.len(), 7);
    assert_eq!(tokens[0].token_type, TokenType::Variable);
    assert_eq!(tokens[0].value, "x");
    assert_eq!(tokens[1].token_type, TokenType::Superscript);
    assert_eq!(tokens[2].value, "2");
}

#[test]
fn test_fraction_parsing() {
    let parser = LatexParser::new();

    let input = r"\frac{1}{2}";
    let tokens = parser.parse(input).unwrap();

    // Verify fraction structure
    assert!(tokens.iter().any(|t| t.token_type == TokenType::Fraction));
    assert_eq!(tokens.iter().filter(|t| t.value == "1").count(), 1);
    assert_eq!(tokens.iter().filter(|t| t.value == "2").count(), 1);
}

#[test]
fn test_matrix_parsing() {
    let parser = LatexParser::new();

    let input = r"\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix}";
    let tokens = parser.parse(input).unwrap();

    assert!(tokens.iter().any(|t| t.token_type == TokenType::MatrixBegin));
    assert!(tokens.iter().any(|t| t.token_type == TokenType::MatrixEnd));

    // Verify matrix elements
    let numbers: Vec<&str> = tokens.iter()
        .filter(|t| t.token_type == TokenType::Number)
        .map(|t| t.value.as_str())
        .collect();
    assert_eq!(numbers, vec!["1", "2", "3", "4"]);
}

#[test]
fn test_nested_expressions() {
    let parser = LatexParser::new();

    let input = r"\frac{x^2 + 1}{x - 1}";
    let tokens = parser.parse(input).unwrap();

    // Verify nested structure is preserved
    assert!(tokens.iter().any(|t| t.token_type == TokenType::Fraction));
    assert!(tokens.iter().any(|t| t.token_type == TokenType::Superscript));
}

#[test]
fn test_special_symbols() {
    let parser = LatexParser::new();

    let symbols = vec![
        (r"\alpha", TokenType::GreekLetter),
        (r"\sum", TokenType::Operator),
        (r"\int", TokenType::Operator),
        (r"\infty", TokenType::Symbol),
        (r"\pi", TokenType::Constant),
    ];

    for (input, expected_type) in symbols {
        let tokens = parser.parse(input).unwrap();
        assert_eq!(tokens[0].token_type, expected_type,
            "Failed for symbol: {}", input);
    }
}

#[test]
fn test_invalid_latex() {
    let parser = LatexParser::new();

    let invalid_inputs = vec![
        r"\frac{1}",           // Missing denominator
        r"\begin{bmatrix}",    // Unclosed environment
        r"x^",                 // Incomplete superscript
        r"\unknown{command}",  // Unknown command
    ];

    for input in invalid_inputs {
        let result = parser.parse(input);
        assert!(result.is_err(),
            "Should fail for invalid input: {}", input);
    }
}

1.3 Output Format Conversion

Test conversion between different output formats.

// tests/unit/format_conversion_tests.rs
use ruvector_scipix::formats::{FormatConverter, OutputFormat};

#[test]
fn test_latex_to_mathml() {
    let converter = FormatConverter::new();

    let latex = r"\frac{1}{2}";
    let mathml = converter.convert(latex, OutputFormat::MathML).unwrap();

    assert!(mathml.contains("<mfrac>"));
    assert!(mathml.contains("<mn>1</mn>"));
    assert!(mathml.contains("<mn>2</mn>"));
}

#[test]
fn test_latex_to_ascii() {
    let converter = FormatConverter::new();

    let latex = r"x^2 + 1";
    let ascii = converter.convert(latex, OutputFormat::AsciiMath).unwrap();

    assert_eq!(ascii, "x^2 + 1");
}

#[test]
fn test_latex_to_unicode() {
    let converter = FormatConverter::new();

    let latex = r"\alpha + \beta";
    let unicode = converter.convert(latex, OutputFormat::Unicode).unwrap();

    assert!(unicode.contains("α"));
    assert!(unicode.contains("β"));
}

#[test]
fn test_latex_to_text() {
    let converter = FormatConverter::new();

    let latex = r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}";
    let text = converter.convert(latex, OutputFormat::PlainText).unwrap();

    // Verify reasonable text representation
    assert!(text.contains("sum"));
    assert!(text.contains("i=1"));
}

#[test]
fn test_format_roundtrip() {
    let converter = FormatConverter::new();

    let original = r"x^2 + 2x + 1";

    // Convert to MathML and back
    let mathml = converter.convert(original, OutputFormat::MathML).unwrap();
    let back_to_latex = converter.convert(&mathml, OutputFormat::LaTeX).unwrap();

    // Should be semantically equivalent (may differ in whitespace)
    assert_eq!(
        normalize_latex(&back_to_latex),
        normalize_latex(original)
    );
}

fn normalize_latex(latex: &str) -> String {
    latex.chars().filter(|c| !c.is_whitespace()).collect()
}

1.4 Configuration Handling

Test configuration loading and validation.

// tests/unit/config_tests.rs
use ruvector_scipix::config::{OCRConfig, ModelConfig, PreprocessingConfig};
use std::path::Path;

#[test]
fn test_default_config() {
    let config = OCRConfig::default();

    assert_eq!(config.model.device, "cpu");
    assert!(config.preprocessing.enable_enhancement);
    assert_eq!(config.output.format, "latex");
}

#[test]
fn test_load_config_from_file() {
    let config = OCRConfig::from_file("testdata/config.toml").unwrap();

    assert!(config.model.model_path.exists());
    assert!(config.preprocessing.target_resolution > 0);
}

#[test]
fn test_config_validation() {
    let mut config = OCRConfig::default();

    // Valid config should pass
    assert!(config.validate().is_ok());

    // Invalid model path should fail
    config.model.model_path = Path::new("/nonexistent/model.onnx").to_path_buf();
    assert!(config.validate().is_err());
}

#[test]
fn test_config_merge() {
    let default = OCRConfig::default();
    let mut custom = OCRConfig::default();
    custom.model.device = "cuda".to_string();
    custom.output.format = "mathml".to_string();

    let merged = default.merge(custom);

    assert_eq!(merged.model.device, "cuda");
    assert_eq!(merged.output.format, "mathml");
    // Default values should be preserved
    assert!(merged.preprocessing.enable_enhancement);
}

#[test]
fn test_config_serialization() {
    let config = OCRConfig::default();

    // Serialize to JSON
    let json = serde_json::to_string(&config).unwrap();

    // Deserialize back
    let deserialized: OCRConfig = serde_json::from_str(&json).unwrap();

    assert_eq!(config.model.device, deserialized.model.device);
    assert_eq!(config.output.format, deserialized.output.format);
}

2. Integration Testing

Integration tests verify the complete pipeline and API endpoints.

2.1 Full Pipeline Tests

Test the complete OCR pipeline from image to LaTeX output.

// tests/integration/pipeline_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig, OCRResult};
use std::fs;

#[test]
fn test_end_to_end_simple_equation() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    let result = ocr.process_image("testdata/simple_equation.png")
        .expect("Failed to process image");

    assert!(!result.latex.is_empty());
    assert!(result.confidence > 0.8);
    assert!(result.latex.contains("x^2") || result.latex.contains("x²"));
}

#[test]
fn test_end_to_end_complex_expression() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    let result = ocr.process_image("testdata/complex_integral.png")
        .expect("Failed to process image");

    assert!(!result.latex.is_empty());
    assert!(result.latex.contains(r"\int"));
    assert!(result.processing_time_ms < 1000);
}

#[test]
fn test_pipeline_with_preprocessing() {
    let mut config = OCRConfig::default();
    config.preprocessing.enable_enhancement = true;
    config.preprocessing.enable_deskew = true;

    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    // Test with skewed/low-quality image
    let result = ocr.process_image("testdata/skewed_equation.png")
        .expect("Failed to process image");

    assert!(!result.latex.is_empty());
    assert!(result.confidence > 0.7);
}

#[test]
fn test_pipeline_error_handling() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    // Test with invalid image
    let result = ocr.process_image("testdata/nonexistent.png");
    assert!(result.is_err());

    // Test with corrupted image
    let result = ocr.process_image("testdata/corrupted.png");
    assert!(result.is_err());

    // Test with non-image file
    let result = ocr.process_image("testdata/text_file.txt");
    assert!(result.is_err());
}

#[test]
fn test_pipeline_batch_processing() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    let images = vec![
        "testdata/equation1.png",
        "testdata/equation2.png",
        "testdata/equation3.png",
    ];

    let results = ocr.process_batch(&images).expect("Batch processing failed");

    assert_eq!(results.len(), 3);
    for result in results {
        assert!(!result.latex.is_empty());
    }
}

2.2 API Endpoint Tests

Test HTTP API endpoints (if applicable).

// tests/integration/api_tests.rs
use ruvector_scipix::server::{start_server, ServerConfig};
use reqwest::multipart;
use tokio;

#[tokio::test]
async fn test_api_health_check() {
    let config = ServerConfig::default();
    let server = start_server(config).await.unwrap();

    let client = reqwest::Client::new();
    let response = client.get("http://localhost:8080/health")
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 200);
}

#[tokio::test]
async fn test_api_ocr_endpoint() {
    let config = ServerConfig::default();
    let _server = start_server(config).await.unwrap();

    let client = reqwest::Client::new();
    let file_content = std::fs::read("testdata/equation.png").unwrap();

    let form = multipart::Form::new()
        .part("image", multipart::Part::bytes(file_content)
            .file_name("equation.png")
            .mime_str("image/png").unwrap());

    let response = client.post("http://localhost:8080/api/v1/ocr")
        .multipart(form)
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 200);

    let result: OCRResult = response.json().await.unwrap();
    assert!(!result.latex.is_empty());
}

#[tokio::test]
async fn test_api_batch_endpoint() {
    let config = ServerConfig::default();
    let _server = start_server(config).await.unwrap();

    let client = reqwest::Client::new();

    let mut form = multipart::Form::new();
    for i in 1..=3 {
        let filename = format!("testdata/equation{}.png", i);
        let content = std::fs::read(&filename).unwrap();
        form = form.part(
            format!("image{}", i),
            multipart::Part::bytes(content)
                .file_name(format!("equation{}.png", i))
                .mime_str("image/png").unwrap()
        );
    }

    let response = client.post("http://localhost:8080/api/v1/batch")
        .multipart(form)
        .send()
        .await
        .unwrap();

    assert_eq!(response.status(), 200);
}

#[tokio::test]
async fn test_api_rate_limiting() {
    let config = ServerConfig {
        rate_limit: Some(5),
        ..Default::default()
    };
    let _server = start_server(config).await.unwrap();

    let client = reqwest::Client::new();

    // Make requests up to limit
    for _ in 0..5 {
        let response = client.get("http://localhost:8080/health")
            .send()
            .await
            .unwrap();
        assert_eq!(response.status(), 200);
    }

    // Next request should be rate limited
    let response = client.get("http://localhost:8080/health")
        .send()
        .await
        .unwrap();
    assert_eq!(response.status(), 429);
}

2.3 Model Loading and Inference

Test model initialization and inference execution.

// tests/integration/model_tests.rs
use ruvector_scipix::model::{ModelLoader, InferenceEngine};
use std::time::Instant;

#[test]
fn test_model_loading() {
    let loader = ModelLoader::new();

    let start = Instant::now();
    let model = loader.load("models/scipix_model.onnx")
        .expect("Failed to load model");
    let load_time = start.elapsed();

    // Model should load in reasonable time
    assert!(load_time.as_secs() < 10,
        "Model loading took too long: {:?}", load_time);

    // Verify model metadata
    assert!(model.input_shape().len() > 0);
    assert!(model.output_shape().len() > 0);
}

#[test]
fn test_model_inference() {
    let loader = ModelLoader::new();
    let model = loader.load("models/scipix_model.onnx").unwrap();

    let engine = InferenceEngine::new(model);

    // Create dummy input tensor
    let input = create_test_tensor(1, 3, 384, 384);

    let start = Instant::now();
    let output = engine.run(&input).expect("Inference failed");
    let inference_time = start.elapsed();

    // Inference should be fast
    assert!(inference_time.as_millis() < 500,
        "Inference too slow: {:?}", inference_time);

    // Output should have expected shape
    assert!(output.len() > 0);
}

#[test]
fn test_gpu_acceleration() {
    if !cuda_available() {
        println!("Skipping GPU test - CUDA not available");
        return;
    }

    let loader = ModelLoader::with_device("cuda");
    let model = loader.load("models/scipix_model.onnx").unwrap();

    let engine = InferenceEngine::new(model);
    let input = create_test_tensor(1, 3, 384, 384);

    let start = Instant::now();
    let _output = engine.run(&input).expect("GPU inference failed");
    let gpu_time = start.elapsed();

    // GPU should be faster than CPU target
    assert!(gpu_time.as_millis() < 200,
        "GPU inference slower than expected: {:?}", gpu_time);
}

#[test]
fn test_model_batch_inference() {
    let loader = ModelLoader::new();
    let model = loader.load("models/scipix_model.onnx").unwrap();
    let engine = InferenceEngine::new(model);

    // Create batch of inputs
    let batch_size = 4;
    let inputs: Vec<_> = (0..batch_size)
        .map(|_| create_test_tensor(1, 3, 384, 384))
        .collect();

    let start = Instant::now();
    let outputs = engine.run_batch(&inputs).expect("Batch inference failed");
    let batch_time = start.elapsed();

    assert_eq!(outputs.len(), batch_size);

    // Batch should be more efficient than individual runs
    let avg_time_per_image = batch_time.as_millis() / batch_size as u128;
    assert!(avg_time_per_image < 300);
}

fn create_test_tensor(batch: usize, channels: usize, height: usize, width: usize) -> Vec<f32> {
    vec![0.5f32; batch * channels * height * width]
}

fn cuda_available() -> bool {
    // Check if CUDA is available
    std::env::var("CUDA_VISIBLE_DEVICES").is_ok()
}

2.4 Multi-Format Support

Test support for different image and output formats.

// tests/integration/format_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig, OutputFormat};

#[test]
fn test_input_format_png() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();
    let result = ocr.process_image("testdata/equation.png");
    assert!(result.is_ok());
}

#[test]
fn test_input_format_jpg() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();
    let result = ocr.process_image("testdata/equation.jpg");
    assert!(result.is_ok());
}

#[test]
fn test_input_format_tiff() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();
    let result = ocr.process_image("testdata/equation.tiff");
    assert!(result.is_ok());
}

#[test]
fn test_output_format_latex() {
    let mut config = OCRConfig::default();
    config.output.format = OutputFormat::LaTeX;

    let ocr = ScipixOCR::new(config).unwrap();
    let result = ocr.process_image("testdata/equation.png").unwrap();

    assert!(result.latex.starts_with("\\") || result.latex.chars().any(|c| c.is_alphanumeric()));
}

#[test]
fn test_output_format_mathml() {
    let mut config = OCRConfig::default();
    config.output.format = OutputFormat::MathML;

    let ocr = ScipixOCR::new(config).unwrap();
    let result = ocr.process_image("testdata/equation.png").unwrap();

    assert!(result.output.contains("<math"));
    assert!(result.output.contains("</math>"));
}

#[test]
fn test_output_format_unicode() {
    let mut config = OCRConfig::default();
    config.output.format = OutputFormat::Unicode;

    let ocr = ScipixOCR::new(config).unwrap();
    let result = ocr.process_image("testdata/greek_symbols.png").unwrap();

    // Should contain Unicode mathematical symbols
    assert!(result.output.chars().any(|c| c as u32 > 0x370));
}

3. Accuracy Testing

Accuracy tests validate OCR quality against ground truth data.

3.1 Character-Level Accuracy (CER)

// tests/accuracy/cer_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use ruvector_scipix::metrics::calculate_cer;
use std::fs;

#[test]
fn test_cer_simple_expressions() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let test_cases = vec![
        ("testdata/simple/001.png", "x^2 + 1"),
        ("testdata/simple/002.png", r"\frac{1}{2}"),
        ("testdata/simple/003.png", "a + b = c"),
    ];

    let mut total_cer = 0.0;
    for (image_path, ground_truth) in test_cases {
        let result = ocr.process_image(image_path).unwrap();
        let cer = calculate_cer(ground_truth, &result.latex);
        total_cer += cer;

        assert!(cer < 0.05, "CER too high for {}: {:.4}", image_path, cer);
    }

    let avg_cer = total_cer / 3.0;
    assert!(avg_cer < 0.02, "Average CER: {:.4}", avg_cer);
}

#[test]
fn test_cer_complex_expressions() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let ground_truth_file = fs::read_to_string("testdata/complex/ground_truth.json").unwrap();
    let ground_truth: Vec<TestCase> = serde_json::from_str(&ground_truth_file).unwrap();

    let mut cer_sum = 0.0;
    let mut count = 0;

    for case in ground_truth {
        let result = ocr.process_image(&case.image_path).unwrap();
        let cer = calculate_cer(&case.latex, &result.latex);
        cer_sum += cer;
        count += 1;

        println!("{}: CER = {:.4}", case.image_path, cer);
    }

    let avg_cer = cer_sum / count as f64;
    assert!(avg_cer < 0.03, "Complex expressions average CER: {:.4}", avg_cer);
}

#[derive(serde::Deserialize)]
struct TestCase {
    image_path: String,
    latex: String,
}

3.2 Expression-Level Accuracy

// tests/accuracy/expression_accuracy_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use ruvector_scipix::metrics::expressions_match;

#[test]
fn test_expression_accuracy_fractions() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let test_cases = vec![
        ("testdata/fractions/simple.png", r"\frac{1}{2}"),
        ("testdata/fractions/complex.png", r"\frac{x^2 + 1}{x - 1}"),
        ("testdata/fractions/nested.png", r"\frac{1}{\frac{1}{2}}"),
    ];

    let mut correct = 0;
    for (image_path, expected) in test_cases.iter() {
        let result = ocr.process_image(image_path).unwrap();
        if expressions_match(&result.latex, expected) {
            correct += 1;
        }
    }

    let accuracy = correct as f64 / test_cases.len() as f64;
    assert!(accuracy >= 0.9, "Fraction accuracy: {:.2}%", accuracy * 100.0);
}

#[test]
fn test_expression_accuracy_matrices() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let test_cases = vec![
        ("testdata/matrices/2x2.png",
         r"\begin{bmatrix} 1 & 2 \\ 3 & 4 \end{bmatrix}"),
        ("testdata/matrices/3x3.png",
         r"\begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \\ 7 & 8 & 9 \end{bmatrix}"),
    ];

    let mut correct = 0;
    for (image_path, expected) in test_cases.iter() {
        let result = ocr.process_image(image_path).unwrap();
        if expressions_match(&result.latex, expected) {
            correct += 1;
        }
    }

    let accuracy = correct as f64 / test_cases.len() as f64;
    assert!(accuracy >= 0.85, "Matrix accuracy: {:.2}%", accuracy * 100.0);
}

#[test]
fn test_expression_accuracy_integrals() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let test_cases = vec![
        ("testdata/integrals/simple.png", r"\int x dx"),
        ("testdata/integrals/limits.png", r"\int_{0}^{1} x^2 dx"),
        ("testdata/integrals/multiple.png", r"\int \int x y dx dy"),
    ];

    let mut correct = 0;
    for (image_path, expected) in test_cases.iter() {
        let result = ocr.process_image(image_path).unwrap();
        if expressions_match(&result.latex, expected) {
            correct += 1;
        }
    }

    let accuracy = correct as f64 / test_cases.len() as f64;
    assert!(accuracy >= 0.80, "Integral accuracy: {:.2}%", accuracy * 100.0);
}

3.3 Cross-Validation with Ground Truth

// tests/accuracy/cross_validation_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use ruvector_scipix::metrics::{calculate_cer, calculate_bleu};
use std::fs;

#[test]
fn test_cross_validation_im2latex() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    // Load Im2latex-100k test split
    let test_data = load_im2latex_test_split("testdata/im2latex/test.json");

    let sample_size = test_data.len().min(1000); // Test on 1000 samples
    let mut cer_sum = 0.0;
    let mut bleu_sum = 0.0;

    for case in test_data.iter().take(sample_size) {
        let result = ocr.process_image(&case.image_path).unwrap();

        let cer = calculate_cer(&case.latex, &result.latex);
        let bleu = calculate_bleu(&case.latex, &result.latex, 4);

        cer_sum += cer;
        bleu_sum += bleu;
    }

    let avg_cer = cer_sum / sample_size as f64;
    let avg_bleu = bleu_sum / sample_size as f64;

    println!("Im2latex cross-validation results:");
    println!("  Average CER: {:.4}", avg_cer);
    println!("  Average BLEU: {:.2}", avg_bleu);

    assert!(avg_cer < 0.03, "CER too high: {:.4}", avg_cer);
    assert!(avg_bleu > 80.0, "BLEU too low: {:.2}", avg_bleu);
}

#[test]
fn test_cross_validation_crohme() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    // Load CROHME handwritten dataset
    let test_data = load_crohme_test_split("testdata/crohme/test.json");

    let mut correct = 0;
    let total = test_data.len();

    for case in test_data {
        let result = ocr.process_image(&case.image_path).unwrap();

        if expressions_match(&result.latex, &case.latex) {
            correct += 1;
        }
    }

    let accuracy = correct as f64 / total as f64;
    println!("CROHME accuracy: {:.2}%", accuracy * 100.0);

    // Handwritten is harder, lower threshold
    assert!(accuracy > 0.70, "CROHME accuracy too low: {:.2}%", accuracy * 100.0);
}

fn load_im2latex_test_split(path: &str) -> Vec<TestCase> {
    let content = fs::read_to_string(path).unwrap();
    serde_json::from_str(&content).unwrap()
}

fn load_crohme_test_split(path: &str) -> Vec<TestCase> {
    let content = fs::read_to_string(path).unwrap();
    serde_json::from_str(&content).unwrap()
}

fn expressions_match(a: &str, b: &str) -> bool {
    // Normalize and compare expressions
    normalize_latex(a) == normalize_latex(b)
}

fn normalize_latex(latex: &str) -> String {
    latex.chars()
        .filter(|c| !c.is_whitespace())
        .collect::<String>()
        .to_lowercase()
}

3.4 Edge Cases Testing

// tests/accuracy/edge_cases_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};

#[test]
fn test_complex_fractions() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let cases = vec![
        "testdata/edge_cases/nested_fractions.png",
        "testdata/edge_cases/compound_fractions.png",
        "testdata/edge_cases/mixed_fractions.png",
    ];

    for case in cases {
        let result = ocr.process_image(case).unwrap();
        assert!(!result.latex.is_empty());
        assert!(result.latex.contains(r"\frac"));
        assert!(result.confidence > 0.6); // Lower threshold for complex cases
    }
}

#[test]
fn test_nested_structures() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let result = ocr.process_image("testdata/edge_cases/deeply_nested.png").unwrap();

    // Verify nested structure is captured
    let brace_count = result.latex.matches('{').count();
    assert!(brace_count >= 4, "Should capture nested structure");
}

#[test]
fn test_special_characters() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let result = ocr.process_image("testdata/edge_cases/special_chars.png").unwrap();

    // Should recognize special mathematical symbols
    let special_symbols = vec![r"\infty", r"\partial", r"\nabla", r"\sum", r"\prod"];
    let recognized = special_symbols.iter()
        .filter(|s| result.latex.contains(*s))
        .count();

    assert!(recognized > 0, "Should recognize special symbols");
}

#[test]
fn test_multi_line_equations() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let result = ocr.process_image("testdata/edge_cases/multi_line.png").unwrap();

    // Should handle multi-line environments
    assert!(
        result.latex.contains(r"\begin{align}") ||
        result.latex.contains(r"\begin{equation}") ||
        result.latex.contains("\\\\")
    );
}

#[test]
fn test_low_quality_images() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let cases = vec![
        "testdata/edge_cases/low_resolution.png",
        "testdata/edge_cases/noisy.png",
        "testdata/edge_cases/blurry.png",
    ];

    for case in cases {
        let result = ocr.process_image(case);

        // Should handle gracefully, even if accuracy is lower
        assert!(result.is_ok(), "Should process {} without crashing", case);

        if let Ok(res) = result {
            assert!(!res.latex.is_empty(), "Should produce some output for {}", case);
        }
    }
}

4. Performance Testing

Performance tests measure latency, throughput, and resource usage using Criterion.

4.1 Latency Benchmarks

// benches/latency_benchmarks.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
use ruvector_scipix::{ScipixOCR, OCRConfig};

fn benchmark_single_image_latency(c: &mut Criterion) {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    c.bench_function("ocr_simple_equation", |b| {
        b.iter(|| {
            ocr.process_image(black_box("testdata/simple_equation.png"))
        });
    });
}

fn benchmark_latency_by_complexity(c: &mut Criterion) {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    let mut group = c.benchmark_group("latency_by_complexity");

    let test_cases = vec![
        ("simple", "testdata/simple.png"),
        ("medium", "testdata/medium.png"),
        ("complex", "testdata/complex.png"),
        ("very_complex", "testdata/very_complex.png"),
    ];

    for (name, path) in test_cases {
        group.bench_with_input(
            BenchmarkId::from_parameter(name),
            &path,
            |b, &path| {
                b.iter(|| ocr.process_image(black_box(path)));
            },
        );
    }

    group.finish();
}

fn benchmark_latency_percentiles(c: &mut Criterion) {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).expect("Failed to initialize OCR");

    let mut group = c.benchmark_group("latency_percentiles");
    group.sample_size(1000); // Large sample for accurate percentiles

    group.bench_function("p50_p95_p99", |b| {
        b.iter(|| {
            ocr.process_image(black_box("testdata/equation.png"))
        });
    });

    group.finish();
}

criterion_group!(
    benches,
    benchmark_single_image_latency,
    benchmark_latency_by_complexity,
    benchmark_latency_percentiles
);
criterion_main!(benches);

4.2 Memory Leak Detection

// tests/performance/memory_leak_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
static DEALLOCATED: AtomicUsize = AtomicUsize::new(0);

struct TrackingAllocator;

unsafe impl GlobalAlloc for TrackingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        ALLOCATED.fetch_add(layout.size(), Ordering::SeqCst);
        System.alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        DEALLOCATED.fetch_add(layout.size(), Ordering::SeqCst);
        System.dealloc(ptr, layout);
    }
}

#[global_allocator]
static GLOBAL: TrackingAllocator = TrackingAllocator;

#[test]
fn test_memory_leak_repeated_processing() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();

    // Clear counters
    let start_allocated = ALLOCATED.load(Ordering::SeqCst);
    let start_deallocated = DEALLOCATED.load(Ordering::SeqCst);
    let start_diff = start_allocated - start_deallocated;

    // Process 100 images
    for _ in 0..100 {
        let _ = ocr.process_image("testdata/equation.png");
    }

    // Force cleanup
    drop(ocr);

    // Allow some time for cleanup
    std::thread::sleep(std::time::Duration::from_millis(100));

    let end_allocated = ALLOCATED.load(Ordering::SeqCst);
    let end_deallocated = DEALLOCATED.load(Ordering::SeqCst);
    let end_diff = end_allocated - end_deallocated;

    let leaked = end_diff - start_diff;

    println!("Memory leaked: {} bytes", leaked);

    // Allow 10MB leak tolerance (for caches, etc.)
    assert!(leaked < 10 * 1024 * 1024,
        "Memory leak detected: {} bytes", leaked);
}

#[test]
fn test_memory_leak_model_reload() {
    let config = OCRConfig::default();

    let start_allocated = ALLOCATED.load(Ordering::SeqCst);
    let start_deallocated = DEALLOCATED.load(Ordering::SeqCst);

    // Load and unload model 10 times
    for _ in 0..10 {
        let ocr = ScipixOCR::new(config.clone()).unwrap();
        let _ = ocr.process_image("testdata/equation.png");
        drop(ocr);
    }

    std::thread::sleep(std::time::Duration::from_millis(100));

    let end_allocated = ALLOCATED.load(Ordering::SeqCst);
    let end_deallocated = DEALLOCATED.load(Ordering::SeqCst);

    let leaked = (end_allocated - start_allocated) - (end_deallocated - start_deallocated);

    println!("Memory leaked after reloads: {} bytes", leaked);

    assert!(leaked < 5 * 1024 * 1024,
        "Memory leak in model reload: {} bytes", leaked);
}

4.3 Stress Testing

// tests/performance/stress_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

#[test]
fn test_sustained_load() {
    let config = OCRConfig::default();
    let ocr = Arc::new(ScipixOCR::new(config).unwrap());

    let duration = Duration::from_secs(60); // 1 minute stress test
    let start = Instant::now();
    let mut count = 0;
    let mut errors = 0;

    while start.elapsed() < duration {
        match ocr.process_image("testdata/equation.png") {
            Ok(_) => count += 1,
            Err(_) => errors += 1,
        }
    }

    println!("Processed {} images in 60 seconds", count);
    println!("Errors: {}", errors);

    assert!(count > 100, "Should process at least 100 images");
    assert!(errors < count / 10, "Error rate too high: {}/{}", errors, count);
}

#[test]
fn test_concurrent_processing() {
    let config = OCRConfig::default();
    let ocr = Arc::new(ScipixOCR::new(config).unwrap());

    let num_threads = 8;
    let images_per_thread = 10;

    let handles: Vec<_> = (0..num_threads)
        .map(|_| {
            let ocr_clone = Arc::clone(&ocr);
            thread::spawn(move || {
                let mut results = Vec::new();
                for _ in 0..images_per_thread {
                    let result = ocr_clone.process_image("testdata/equation.png");
                    results.push(result.is_ok());
                }
                results
            })
        })
        .collect();

    let results: Vec<_> = handles.into_iter()
        .map(|h| h.join().unwrap())
        .flatten()
        .collect();

    let success_count = results.iter().filter(|&&x| x).count();
    let total = num_threads * images_per_thread;

    println!("Concurrent processing: {}/{} successful", success_count, total);

    assert!(success_count >= (total * 95) / 100,
        "Success rate too low: {}/{}", success_count, total);
}

#[test]
fn test_memory_under_load() {
    use sysinfo::{System, SystemExt};

    let mut sys = System::new_all();
    sys.refresh_memory();

    let start_memory = sys.used_memory();

    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();

    // Process 1000 images
    for i in 0..1000 {
        let _ = ocr.process_image("testdata/equation.png");

        if i % 100 == 0 {
            sys.refresh_memory();
            let current_memory = sys.used_memory();
            println!("Memory at {}: {} KB", i, current_memory);
        }
    }

    sys.refresh_memory();
    let end_memory = sys.used_memory();
    let memory_growth = end_memory - start_memory;

    println!("Memory growth: {} KB", memory_growth);

    // Should not grow more than 500MB
    assert!(memory_growth < 500 * 1024,
        "Excessive memory growth: {} KB", memory_growth);
}

4.4 Concurrency Testing

// tests/performance/concurrency_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Instant;

#[test]
fn test_thread_safety() {
    let config = OCRConfig::default();
    let ocr = Arc::new(ScipixOCR::new(config).unwrap());

    let num_threads = 16;
    let barrier = Arc::new(Barrier::new(num_threads));

    let handles: Vec<_> = (0..num_threads)
        .map(|i| {
            let ocr_clone = Arc::clone(&ocr);
            let barrier_clone = Arc::clone(&barrier);

            thread::spawn(move || {
                // Wait for all threads to be ready
                barrier_clone.wait();

                // All threads process simultaneously
                let result = ocr_clone.process_image("testdata/equation.png");

                (i, result.is_ok())
            })
        })
        .collect();

    let results: Vec<_> = handles.into_iter()
        .map(|h| h.join().unwrap())
        .collect();

    let all_successful = results.iter().all(|(_, success)| *success);
    assert!(all_successful, "Some threads failed: {:?}", results);
}

#[test]
fn test_concurrent_throughput() {
    let config = OCRConfig::default();
    let ocr = Arc::new(ScipixOCR::new(config).unwrap());

    let num_threads = 8;
    let images_per_thread = 50;

    let start = Instant::now();

    let handles: Vec<_> = (0..num_threads)
        .map(|_| {
            let ocr_clone = Arc::clone(&ocr);
            thread::spawn(move || {
                for _ in 0..images_per_thread {
                    let _ = ocr_clone.process_image("testdata/equation.png");
                }
            })
        })
        .collect();

    for handle in handles {
        handle.join().unwrap();
    }

    let duration = start.elapsed();
    let total_images = num_threads * images_per_thread;
    let throughput = total_images as f64 / duration.as_secs_f64();

    println!("Concurrent throughput: {:.2} images/sec", throughput);

    assert!(throughput > 5.0,
        "Throughput too low: {:.2} images/sec", throughput);
}

5. Regression Testing

Track performance and accuracy regressions over time.

5.1 Golden File Comparison

// tests/regression/golden_file_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use std::fs;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
struct GoldenResult {
    image: String,
    latex: String,
    confidence: f64,
}

#[test]
fn test_golden_file_consistency() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();

    // Load golden results
    let golden_path = "testdata/regression/golden_results.json";
    let golden_content = fs::read_to_string(golden_path).unwrap();
    let golden_results: Vec<GoldenResult> = serde_json::from_str(&golden_content).unwrap();

    let mut matches = 0;
    let mut total = 0;

    for golden in golden_results {
        let result = ocr.process_image(&golden.image).unwrap();
        total += 1;

        if normalize_latex(&result.latex) == normalize_latex(&golden.latex) {
            matches += 1;
        } else {
            println!("Mismatch for {}:", golden.image);
            println!("  Expected: {}", golden.latex);
            println!("  Got:      {}", result.latex);
        }
    }

    let consistency = matches as f64 / total as f64;
    println!("Golden file consistency: {:.2}%", consistency * 100.0);

    assert!(consistency >= 0.95,
        "Regression detected: only {:.2}% match", consistency * 100.0);
}

#[test]
#[ignore] // Run manually to update golden files
fn update_golden_files() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();

    let test_images = vec![
        "testdata/regression/test1.png",
        "testdata/regression/test2.png",
        "testdata/regression/test3.png",
    ];

    let mut golden_results = Vec::new();

    for image in test_images {
        let result = ocr.process_image(image).unwrap();
        golden_results.push(GoldenResult {
            image: image.to_string(),
            latex: result.latex,
            confidence: result.confidence,
        });
    }

    let json = serde_json::to_string_pretty(&golden_results).unwrap();
    fs::write("testdata/regression/golden_results.json", json).unwrap();

    println!("Golden files updated");
}

fn normalize_latex(latex: &str) -> String {
    latex.chars()
        .filter(|c| !c.is_whitespace())
        .collect()
}

5.2 Baseline Accuracy Tracking

// tests/regression/accuracy_tracking_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use ruvector_scipix::metrics::calculate_cer;
use std::fs;
use serde::{Deserialize, Serialize};
use chrono::Utc;

#[derive(Serialize, Deserialize)]
struct AccuracyBaseline {
    timestamp: String,
    commit_hash: String,
    average_cer: f64,
    average_confidence: f64,
    test_count: usize,
}

#[test]
fn test_accuracy_regression() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();

    // Load baseline
    let baseline_path = "testdata/regression/accuracy_baseline.json";
    let baseline: AccuracyBaseline = if std::path::Path::new(baseline_path).exists() {
        let content = fs::read_to_string(baseline_path).unwrap();
        serde_json::from_str(&content).unwrap()
    } else {
        AccuracyBaseline {
            timestamp: Utc::now().to_rfc3339(),
            commit_hash: get_git_commit(),
            average_cer: 0.02,
            average_confidence: 0.90,
            test_count: 0,
        }
    };

    // Run current tests
    let test_cases = load_test_cases("testdata/regression/test_suite.json");

    let mut cer_sum = 0.0;
    let mut confidence_sum = 0.0;

    for case in &test_cases {
        let result = ocr.process_image(&case.image).unwrap();
        let cer = calculate_cer(&case.latex, &result.latex);

        cer_sum += cer;
        confidence_sum += result.confidence;
    }

    let current_avg_cer = cer_sum / test_cases.len() as f64;
    let current_avg_confidence = confidence_sum / test_cases.len() as f64;

    println!("Accuracy comparison:");
    println!("  Baseline CER: {:.4}", baseline.average_cer);
    println!("  Current CER:  {:.4}", current_avg_cer);
    println!("  Baseline Confidence: {:.4}", baseline.average_confidence);
    println!("  Current Confidence:  {:.4}", current_avg_confidence);

    // Check for regressions (allow 10% tolerance)
    let cer_regression = (current_avg_cer - baseline.average_cer) / baseline.average_cer;
    assert!(cer_regression < 0.10,
        "CER regression detected: {:.2}%", cer_regression * 100.0);

    let confidence_regression = (baseline.average_confidence - current_avg_confidence) / baseline.average_confidence;
    assert!(confidence_regression < 0.10,
        "Confidence regression detected: {:.2}%", confidence_regression * 100.0);
}

#[derive(Deserialize)]
struct TestCase {
    image: String,
    latex: String,
}

fn load_test_cases(path: &str) -> Vec<TestCase> {
    let content = fs::read_to_string(path).unwrap();
    serde_json::from_str(&content).unwrap()
}

fn get_git_commit() -> String {
    std::process::Command::new("git")
        .args(&["rev-parse", "HEAD"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_else(|_| "unknown".to_string())
}

5.3 API Compatibility Checks

// tests/regression/api_compatibility_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig, OCRResult};

#[test]
fn test_api_backward_compatibility() {
    // Verify OCRConfig fields
    let config = OCRConfig::default();
    let _ = config.model;
    let _ = config.preprocessing;
    let _ = config.output;

    // Verify OCRResult fields
    let ocr = ScipixOCR::new(config).unwrap();
    let result = ocr.process_image("testdata/equation.png").unwrap();

    let _ = result.latex;
    let _ = result.confidence;
    let _ = result.processing_time_ms;
    let _ = result.detected_symbols;

    // All fields should be accessible without breaking changes
}

#[test]
fn test_serialization_compatibility() {
    let config = OCRConfig::default();
    let ocr = ScipixOCR::new(config).unwrap();
    let result = ocr.process_image("testdata/equation.png").unwrap();

    // Should serialize/deserialize without errors
    let json = serde_json::to_string(&result).unwrap();
    let deserialized: OCRResult = serde_json::from_str(&json).unwrap();

    assert_eq!(result.latex, deserialized.latex);
    assert_eq!(result.confidence, deserialized.confidence);
}

6. Fuzz Testing

Fuzz testing to discover edge cases and improve robustness.

6.1 Image Corruption Handling

// tests/fuzz/image_corruption_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};
use image::{DynamicImage, GenericImageView};
use rand::Rng;

#[test]
fn test_fuzz_random_noise() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let base_image = image::open("testdata/equation.png").unwrap();

    for _ in 0..10 {
        let mut noisy = base_image.clone().into_rgba8();
        add_random_noise(&mut noisy, 0.1);

        let noisy_image = DynamicImage::ImageRgba8(noisy);
        noisy_image.save("/tmp/noisy.png").unwrap();

        // Should handle noisy images without crashing
        let result = ocr.process_image("/tmp/noisy.png");
        assert!(result.is_ok() || result.is_err(),
            "Should return valid result or error");
    }
}

#[test]
fn test_fuzz_pixel_corruption() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let base_image = image::open("testdata/equation.png").unwrap();

    for corruption_rate in [0.01, 0.05, 0.10, 0.20] {
        let mut corrupted = base_image.clone().into_rgba8();
        corrupt_pixels(&mut corrupted, corruption_rate);

        let corrupted_image = DynamicImage::ImageRgba8(corrupted);
        corrupted_image.save("/tmp/corrupted.png").unwrap();

        let result = ocr.process_image("/tmp/corrupted.png");

        // Even with corruption, should not crash
        match result {
            Ok(res) => println!("Corrupted {:.0}%: confidence = {:.2}",
                corruption_rate * 100.0, res.confidence),
            Err(e) => println!("Corrupted {:.0}%: error = {:?}",
                corruption_rate * 100.0, e),
        }
    }
}

fn add_random_noise(img: &mut image::RgbaImage, intensity: f32) {
    let mut rng = rand::thread_rng();

    for pixel in img.pixels_mut() {
        for channel in 0..3 {
            let noise = rng.gen_range(-intensity..intensity) * 255.0;
            let new_value = (pixel[channel] as f32 + noise).clamp(0.0, 255.0) as u8;
            pixel[channel] = new_value;
        }
    }
}

fn corrupt_pixels(img: &mut image::RgbaImage, rate: f32) {
    let mut rng = rand::thread_rng();
    let (width, height) = img.dimensions();

    for y in 0..height {
        for x in 0..width {
            if rng.gen::<f32>() < rate {
                let pixel = img.get_pixel_mut(x, y);
                pixel[0] = rng.gen();
                pixel[1] = rng.gen();
                pixel[2] = rng.gen();
            }
        }
    }
}

6.2 Invalid Input Resilience

// tests/fuzz/invalid_input_tests.rs
use ruvector_scipix::{ScipixOCR, OCRConfig};

#[test]
fn test_fuzz_invalid_file_paths() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    let invalid_paths = vec![
        "",
        "/nonexistent/path/image.png",
        "../../../etc/passwd",
        "testdata/\0null_byte.png",
        "x".repeat(10000), // Very long path
    ];

    for path in invalid_paths {
        let result = ocr.process_image(path);
        assert!(result.is_err(), "Should reject invalid path: {}", path);
    }
}

#[test]
fn test_fuzz_malformed_images() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    // Create malformed image files
    std::fs::write("/tmp/empty.png", b"").unwrap();
    std::fs::write("/tmp/random.png", &[0u8; 1000]).unwrap();
    std::fs::write("/tmp/truncated.png", &[137, 80, 78, 71]).unwrap(); // PNG header only

    let malformed = vec![
        "/tmp/empty.png",
        "/tmp/random.png",
        "/tmp/truncated.png",
    ];

    for path in malformed {
        let result = ocr.process_image(path);
        assert!(result.is_err(), "Should reject malformed image: {}", path);
    }
}

#[test]
fn test_fuzz_extreme_dimensions() {
    let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();

    // Create images with extreme dimensions
    let tiny = image::RgbaImage::new(1, 1);
    tiny.save("/tmp/tiny.png").unwrap();

    let wide = image::RgbaImage::new(10000, 10);
    wide.save("/tmp/wide.png").unwrap();

    let tall = image::RgbaImage::new(10, 10000);
    tall.save("/tmp/tall.png").unwrap();

    // Should handle gracefully
    let _ = ocr.process_image("/tmp/tiny.png");
    let _ = ocr.process_image("/tmp/wide.png");
    let _ = ocr.process_image("/tmp/tall.png");
}

6.3 Buffer Overflow Prevention

// tests/fuzz/buffer_overflow_tests.rs
use ruvector_scipix::latex::LatexParser;

#[test]
fn test_fuzz_latex_parser_large_input() {
    let parser = LatexParser::new();

    // Very long valid LaTeX
    let long_valid = "x + ".repeat(10000) + "1";
    let result = parser.parse(&long_valid);

    // Should handle without crash or overflow
    assert!(result.is_ok() || result.is_err());
}

#[test]
fn test_fuzz_deeply_nested_latex() {
    let parser = LatexParser::new();

    // Deeply nested braces
    let mut nested = String::new();
    for _ in 0..1000 {
        nested.push_str(r"\frac{");
    }
    nested.push('1');
    for _ in 0..1000 {
        nested.push('}');
    }

    let result = parser.parse(&nested);

    // Should either parse or reject gracefully
    match result {
        Ok(_) => println!("Parsed deeply nested structure"),
        Err(e) => println!("Rejected nested structure: {:?}", e),
    }
}

#[test]
fn test_fuzz_special_characters() {
    let parser = LatexParser::new();

    let special_inputs = vec![
        "\0\0\0",
        "\u{FFFF}".repeat(100),
        "\\".repeat(1000),
        "{}{}{}".repeat(1000),
        r"\begin{matrix}".repeat(100),
    ];

    for input in special_inputs {
        let result = parser.parse(&input);
        // Should not crash, regardless of output
        let _ = result;
    }
}

7. Test Data Management

7.1 Synthetic Test Data Generation

// tests/testdata/synthetic_generator.rs
use image::{RgbaImage, Rgba};
use rusttype::{Font, Scale, point};

pub struct SyntheticDataGenerator {
    font: Font<'static>,
}

impl SyntheticDataGenerator {
    pub fn new() -> Self {
        let font_data = include_bytes!("../../assets/fonts/DejaVuSans.ttf");
        let font = Font::try_from_bytes(font_data as &[u8]).unwrap();

        Self { font }
    }

    pub fn generate_simple_equation(&self, equation: &str) -> RgbaImage {
        let scale = Scale::uniform(32.0);
        let mut image = RgbaImage::from_pixel(400, 100, Rgba([255, 255, 255, 255]));

        imageproc::drawing::draw_text_mut(
            &mut image,
            Rgba([0, 0, 0, 255]),
            10,
            30,
            scale,
            &self.font,
            equation
        );

        image
    }

    pub fn generate_with_noise(&self, equation: &str, noise_level: f32) -> RgbaImage {
        let mut image = self.generate_simple_equation(equation);

        let mut rng = rand::thread_rng();
        for pixel in image.pixels_mut() {
            if rng.gen::<f32>() < noise_level {
                pixel[0] = rng.gen();
                pixel[1] = rng.gen();
                pixel[2] = rng.gen();
            }
        }

        image
    }

    pub fn generate_batch(&self, equations: &[&str], output_dir: &str) -> std::io::Result<()> {
        std::fs::create_dir_all(output_dir)?;

        for (i, equation) in equations.iter().enumerate() {
            let image = self.generate_simple_equation(equation);
            let path = format!("{}/equation_{:04}.png", output_dir, i);
            image.save(&path).unwrap();
        }

        Ok(())
    }
}

#[test]
fn test_generate_test_dataset() {
    let generator = SyntheticDataGenerator::new();

    let equations = vec![
        "x^2 + 1",
        "a + b = c",
        "f(x) = 2x + 1",
        "∫ x dx",
        "∑ i=1 to n",
    ];

    generator.generate_batch(&equations, "testdata/synthetic").unwrap();
}

7.2 Real-World Sample Collection

// tests/testdata/sample_collector.rs
use std::fs;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct SampleMetadata {
    pub image_path: String,
    pub source: String,
    pub difficulty: String,
    pub latex_ground_truth: Option<String>,
    pub tags: Vec<String>,
}

pub struct SampleCollector {
    output_dir: String,
}

impl SampleCollector {
    pub fn new(output_dir: &str) -> Self {
        fs::create_dir_all(output_dir).unwrap();
        Self {
            output_dir: output_dir.to_string(),
        }
    }

    pub fn add_sample(&self,
        image_path: &str,
        metadata: SampleMetadata
    ) -> std::io::Result<()> {
        // Copy image to collection
        let filename = std::path::Path::new(image_path)
            .file_name()
            .unwrap()
            .to_str()
            .unwrap();

        let dest_path = format!("{}/{}", self.output_dir, filename);
        fs::copy(image_path, &dest_path)?;

        // Save metadata
        let metadata_path = format!("{}/{}.json", self.output_dir, filename);
        let json = serde_json::to_string_pretty(&metadata)?;
        fs::write(metadata_path, json)?;

        Ok(())
    }

    pub fn export_manifest(&self) -> std::io::Result<()> {
        let mut samples = Vec::new();

        for entry in fs::read_dir(&self.output_dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.extension().and_then(|s| s.to_str()) == Some("json") {
                let content = fs::read_to_string(&path)?;
                let metadata: SampleMetadata = serde_json::from_str(&content)?;
                samples.push(metadata);
            }
        }

        let manifest = serde_json::to_string_pretty(&samples)?;
        fs::write(format!("{}/manifest.json", self.output_dir), manifest)?;

        Ok(())
    }
}

7.3 Ground Truth Annotation Format

// tests/testdata/ground_truth.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Clone)]
pub struct GroundTruthEntry {
    pub image_id: String,
    pub image_path: String,
    pub latex: String,
    pub mathml: Option<String>,
    pub ascii_math: Option<String>,
    pub difficulty_level: u8,
    pub symbol_count: usize,
    pub contains_matrices: bool,
    pub contains_fractions: bool,
    pub contains_integrals: bool,
    pub annotations: HashMap<String, String>,
}

#[derive(Serialize, Deserialize)]
pub struct GroundTruthDataset {
    pub version: String,
    pub created_at: String,
    pub description: String,
    pub entries: Vec<GroundTruthEntry>,
}

impl GroundTruthDataset {
    pub fn new(description: &str) -> Self {
        Self {
            version: "1.0.0".to_string(),
            created_at: chrono::Utc::now().to_rfc3339(),
            description: description.to_string(),
            entries: Vec::new(),
        }
    }

    pub fn add_entry(&mut self, entry: GroundTruthEntry) {
        self.entries.push(entry);
    }

    pub fn save(&self, path: &str) -> std::io::Result<()> {
        let json = serde_json::to_string_pretty(self)?;
        std::fs::write(path, json)?;
        Ok(())
    }

    pub fn load(path: &str) -> std::io::Result<Self> {
        let content = std::fs::read_to_string(path)?;
        let dataset = serde_json::from_str(&content)?;
        Ok(dataset)
    }

    pub fn filter_by_difficulty(&self, level: u8) -> Vec<&GroundTruthEntry> {
        self.entries.iter()
            .filter(|e| e.difficulty_level == level)
            .collect()
    }
}

8. CI/CD Integration

8.1 GitHub Actions Workflow

# .github/workflows/test.yml
name: Comprehensive Testing

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  RUST_BACKTRACE: 1
  RUSTFLAGS: "-D warnings"

jobs:
  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true
          components: rustfmt, clippy

      - name: Cache cargo registry
        uses: actions/cache@v3
        with:
          path: ~/.cargo/registry
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}

      - name: Cache cargo index
        uses: actions/cache@v3
        with:
          path: ~/.cargo/git
          key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}

      - name: Cache cargo build
        uses: actions/cache@v3
        with:
          path: target
          key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}

      - name: Run unit tests
        run: cargo test --lib --all-features

      - name: Run doc tests
        run: cargo test --doc

  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true

      - name: Download test datasets
        run: |
          mkdir -p testdata
          # Download sample images
          wget -O testdata/equation.png https://example.com/test-images/equation.png

      - name: Run integration tests
        run: cargo test --test '*' --all-features

  accuracy-tests:
    name: Accuracy Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - name: Download ground truth dataset
        run: |
          mkdir -p testdata/accuracy
          # Download Im2latex test split
          wget https://example.com/datasets/im2latex_test.tar.gz
          tar -xzf im2latex_test.tar.gz -C testdata/accuracy

      - name: Run accuracy tests
        run: cargo test --test accuracy_tests

      - name: Check accuracy threshold
        run: |
          cargo run --bin check_accuracy -- \
            --threshold 0.02 \
            --dataset testdata/accuracy

  performance-tests:
    name: Performance Benchmarks
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - name: Run benchmarks
        run: cargo bench --bench '*' -- --save-baseline current

      - name: Download baseline
        id: download-baseline
        continue-on-error: true
        run: |
          gh release download baseline \
            --pattern 'benchmark_baseline.json' \
            --dir target/criterion
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Compare with baseline
        if: steps.download-baseline.outcome == 'success'
        run: |
          cargo install critcmp
          critcmp baseline current

      - name: Upload benchmark results
        uses: actions/upload-artifact@v3
        with:
          name: benchmark-results
          path: target/criterion/

  regression-tests:
    name: Regression Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - name: Download regression baseline
        run: |
          gh release download regression-baseline \
            --pattern 'golden_results.json' \
            --dir testdata/regression
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Run regression tests
        run: cargo test --test regression_tests

  coverage:
    name: Code Coverage
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true

      - name: Install tarpaulin
        run: cargo install cargo-tarpaulin

      - name: Generate coverage
        run: |
          cargo tarpaulin --out Xml --output-dir ./coverage \
            --all-features --workspace

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/cobertura.xml
          fail_ci_if_error: true

      - name: Check coverage threshold
        run: |
          COVERAGE=$(cargo tarpaulin --output-dir ./coverage --all-features | \
            grep -oP '\d+\.\d+%' | head -1 | grep -oP '\d+\.\d+')

          if (( $(echo "$COVERAGE < 80.0" | bc -l) )); then
            echo "Coverage $COVERAGE% is below 80% threshold"
            exit 1
          fi

8.2 Test Coverage Requirements (80%+)

// tests/coverage/coverage_check.rs
use std::process::Command;

#[test]
#[ignore] // Run separately with `cargo test --ignored`
fn check_test_coverage() {
    let output = Command::new("cargo")
        .args(&["tarpaulin", "--output-dir", "./coverage", "--all-features"])
        .output()
        .expect("Failed to run cargo tarpaulin");

    let stdout = String::from_utf8_lossy(&output.stdout);

    // Parse coverage percentage
    let coverage_line = stdout.lines()
        .find(|line| line.contains("Coverage:"))
        .expect("Could not find coverage line");

    let coverage: f64 = coverage_line
        .split_whitespace()
        .find_map(|s| s.trim_end_matches('%').parse().ok())
        .expect("Could not parse coverage percentage");

    println!("Test coverage: {:.2}%", coverage);

    assert!(coverage >= 80.0,
        "Test coverage {:.2}% is below 80% threshold", coverage);
}

8.3 Automated Regression Detection

See earlier section on Regression Testing (5.2).


9. Property-Based Testing

Property-based testing with proptest for invariant checking.

9.1 Proptest for Invariants

// tests/property/preprocessing_properties.rs
use proptest::prelude::*;
use ruvector_scipix::preprocessing::{ImageEnhancer, Binarizer};
use image::{GrayImage, Luma};

proptest! {
    #[test]
    fn test_binarization_only_produces_binary_values(
        width in 10u32..200u32,
        height in 10u32..200u32
    ) {
        let img = generate_random_image(width, height);
        let binarizer = Binarizer;
        let binary = binarizer.otsu_binarize(&img);

        // Property: All pixels should be either 0 or 255
        for pixel in binary.pixels() {
            prop_assert!(pixel[0] == 0 || pixel[0] == 255);
        }
    }

    #[test]
    fn test_enhancement_preserves_dimensions(
        width in 10u32..200u32,
        height in 10u32..200u32
    ) {
        let img = generate_random_image(width, height);
        let enhancer = ImageEnhancer::new();
        let enhanced = enhancer.apply_clahe(&img);

        // Property: Dimensions should be preserved
        prop_assert_eq!(enhanced.width(), width);
        prop_assert_eq!(enhanced.height(), height);
    }

    #[test]
    fn test_rotation_inverse_property(
        angle in -180.0f32..180.0f32
    ) {
        let img = load_test_image();
        let detector = RotationDetector;

        // Rotate and rotate back
        let rotated = detector.rotate_image(&img, angle);
        let back = detector.rotate_image(&rotated, -angle);

        // Property: Should be close to original (allowing for interpolation error)
        let similarity = image_similarity(&img, &back);
        prop_assert!(similarity > 0.95,
            "Similarity {} too low for angle {}", similarity, angle);
    }
}

fn generate_random_image(width: u32, height: u32) -> GrayImage {
    use rand::Rng;
    let mut rng = rand::thread_rng();

    GrayImage::from_fn(width, height, |_, _| {
        Luma([rng.gen_range(0..256)])
    })
}

fn load_test_image() -> GrayImage {
    image::open("testdata/simple.png").unwrap().to_luma8()
}

fn image_similarity(a: &GrayImage, b: &GrayImage) -> f64 {
    if a.dimensions() != b.dimensions() {
        return 0.0;
    }

    let total_pixels = (a.width() * a.height()) as f64;
    let matching_pixels = a.pixels()
        .zip(b.pixels())
        .filter(|(p1, p2)| (p1[0] as i32 - p2[0] as i32).abs() < 10)
        .count() as f64;

    matching_pixels / total_pixels
}

9.2 Roundtrip Testing (Image → LaTeX → Render → Compare)

// tests/property/roundtrip_tests.rs
use proptest::prelude::*;
use ruvector_scipix::{ScipixOCR, OCRConfig};
use ruvector_scipix::render::LatexRenderer;

proptest! {
    #[test]
    fn test_latex_roundtrip_simple_expressions(
        a in 1i32..100,
        b in 1i32..100,
        op in prop::sample::select(vec!['+', '-', '*'])
    ) {
        let latex = format!("{} {} {}", a, op, b);

        // Render to image
        let renderer = LatexRenderer::new();
        let image = renderer.render(&latex).unwrap();
        image.save("/tmp/roundtrip.png").unwrap();

        // OCR back to LaTeX
        let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();
        let result = ocr.process_image("/tmp/roundtrip.png").unwrap();

        // Property: Should recognize the expression
        let normalized_original = normalize_latex(&latex);
        let normalized_result = normalize_latex(&result.latex);

        prop_assert_eq!(normalized_original, normalized_result);
    }

    #[test]
    fn test_latex_roundtrip_fractions(
        numerator in 1i32..20,
        denominator in 1i32..20
    ) {
        let latex = format!(r"\frac{{{}}}{{{}}}", numerator, denominator);

        let renderer = LatexRenderer::new();
        let image = renderer.render(&latex).unwrap();
        image.save("/tmp/fraction_roundtrip.png").unwrap();

        let ocr = ScipixOCR::new(OCRConfig::default()).unwrap();
        let result = ocr.process_image("/tmp/fraction_roundtrip.png").unwrap();

        // Property: Should contain fraction with correct numerator and denominator
        prop_assert!(result.latex.contains(r"\frac"));
        prop_assert!(result.latex.contains(&numerator.to_string()));
        prop_assert!(result.latex.contains(&denominator.to_string()));
    }
}

fn normalize_latex(latex: &str) -> String {
    latex.chars()
        .filter(|c| !c.is_whitespace())
        .collect::<String>()
        .to_lowercase()
}

10. Test Coverage Requirements

Target Coverage Metrics

  • Overall Coverage: 80%+
  • Critical Paths: 95%+
  • Unit Tests: 90%+
  • Integration Tests: 80%+

Coverage Enforcement

# Run with coverage
cargo tarpaulin --out Html --output-dir coverage --all-features

# Check threshold
cargo tarpaulin --fail-under 80

# Generate detailed report
cargo tarpaulin --out Lcov --output-dir coverage

Per-Module Requirements

// Each module should have comprehensive tests
mod preprocessing {
    // Unit tests for each function
    #[cfg(test)]
    mod tests {
        // Test coverage: 90%+
    }
}

mod model {
    #[cfg(test)]
    mod tests {
        // Test coverage: 85%+
    }
}

mod api {
    #[cfg(test)]
    mod tests {
        // Test coverage: 80%+
    }
}

Running Tests

# Run all tests
cargo test --all-features

# Run specific test suite
cargo test --test unit_tests
cargo test --test integration_tests
cargo test --test accuracy_tests

# Run benchmarks
cargo bench

# Run with coverage
cargo tarpaulin --all-features

# Run property tests
cargo test --test property_tests

# Run fuzz tests
cargo test --test fuzz_tests

Conclusion

This comprehensive testing strategy ensures the ruvector-scipix OCR system maintains high quality, performance, and reliability through:

  1. Extensive Unit Testing - Individual component validation
  2. Integration Testing - End-to-end pipeline verification
  3. Accuracy Validation - CER, WER, BLEU metrics against ground truth
  4. Performance Benchmarking - Latency, throughput, and resource tracking
  5. Regression Protection - Golden file comparison and baseline tracking
  6. Robustness Testing - Fuzz testing for edge cases
  7. Automated CI/CD - Continuous testing and coverage enforcement
  8. Property-Based Testing - Invariant checking with proptest

Test Execution Summary:

  • 500+ unit tests
  • 100+ integration tests
  • 50+ accuracy tests
  • 20+ performance benchmarks
  • 30+ regression tests
  • 40+ fuzz tests
  • 80%+ code coverage requirement

This strategy provides confidence in code quality, prevents regressions, and ensures the OCR system meets production requirements.