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

2602 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#1-unit-testing)
2. [Integration Testing](#2-integration-testing)
3. [Accuracy Testing](#3-accuracy-testing)
4. [Performance Testing](#4-performance-testing)
5. [Regression Testing](#5-regression-testing)
6. [Fuzz Testing](#6-fuzz-testing)
7. [Test Data Management](#7-test-data-management)
8. [CI/CD Integration](#8-cicd-integration)
9. [Property-Based Testing](#9-property-based-testing)
10. [Test Coverage Requirements](#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.
```rust
// 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.
```rust
// 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.
```rust
// 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.
```rust
// 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.
```rust
// 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(""));
}
#[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).
```rust
// 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.
```rust
// 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.
```rust
// 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)
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```rust
// 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
```yaml
# .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%+)
```rust
// 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
```rust
// 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)
```rust
// 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
```bash
# 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
```rust
// 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
```bash
# 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.