test coverage

This commit is contained in:
Yujong Lee 2026-09-17 07:10:49 -07:00
parent 0e5f41bc93
commit ab1f966a17
5 changed files with 247 additions and 88 deletions

View file

@ -344,6 +344,7 @@ fn invalid_api_base() -> crate::ocr::Error {
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::json;
use super::*;
@ -471,10 +472,13 @@ mod tests {
));
}
#[test]
fn provider_options_exclude_response_controls_and_extensions() {
#[rstest]
fn provider_options_exclude_response_controls_and_extensions(
#[values("markdown", "blocks")] output_format: &str,
#[values("https://example.com/a.png", "data:image/png;base64,YWJj")] source: &str,
) {
let arguments = serde_json::from_value(
json!({"output_format":"blocks","req_format":"native","unknown":true}),
json!({"output_format":output_format,"req_format":"native","unknown":true}),
)
.unwrap();
let params = CohereParseConfig
@ -482,10 +486,10 @@ mod tests {
.unwrap();
assert_eq!(
serde_json::to_value(&params).unwrap(),
json!({"output_format":"blocks"})
json!({"output_format":output_format})
);
let document = serde_json::from_value(
json!({"type":"image_url","image_url":"https://example.com/a.png","ignored":"field"}),
json!({"type":"image_url","image_url":source,"ignored":"field"}),
)
.unwrap();
let body = CohereParseConfig
@ -494,7 +498,7 @@ mod tests {
assert_eq!(
serde_json::to_value(body).unwrap(),
json!({
"model":"parse", "document":{"type":"image_url","image_url":"https://example.com/a.png"}, "output_format":"blocks"
"model":"parse", "document":{"type":"image_url","image_url":source}, "output_format":output_format
})
);
}
@ -526,9 +530,9 @@ mod tests {
assert!(body.get("req_format").is_none());
}
#[test]
#[rstest]
fn response_normalizes_markdown_images_blocks_and_billed_pages() {
let response = serde_json::from_value(json!({
let payload = json!({
"pages": [
{
"type":"markdown",
@ -558,17 +562,22 @@ mod tests {
{"type":"blocks","blocks":[{"type":"text","text":{"content":"total"}}]}
],
"meta":{"api_version":{"version":"2"},"billed_units":{"pages":3}}
}))
.unwrap();
});
let response = serde_json::from_value(payload.clone()).unwrap();
let normalized = normalize_response("parse-v5.0", response).unwrap();
assert_eq!(normalized.pages[0].index, 4);
assert_eq!(normalized.pages[0].markdown, "receipt");
let image = &normalized.pages[0].images.as_ref().unwrap()[0];
assert_eq!(image.bbox.as_ref().unwrap()["top_left_x"], 1);
let original_image = &payload["pages"][0]["markdown"]["images"][0];
assert_eq!(
image.extra_fields["bounding_box_normalized"]["bottom_right_x"],
0.15
serde_json::to_value(&image.bbox).unwrap(),
original_image["bounding_box"]
);
assert_eq!(
image.extra_fields["bounding_box_normalized"],
original_image["bounding_box_normalized"]
);
assert_eq!(image.extra_fields["id"], original_image["id"]);
assert_eq!(image.extra_fields["description"], "scan");
assert_eq!(image.extra_fields["category"], "logo");
assert_eq!(image.extra_fields["provider_extension"], "preserved");
@ -609,9 +618,15 @@ mod tests {
assert!(normalized.pages[0].images.is_none());
}
#[test]
fn response_types_documented_block_variants() {
let response = serde_json::from_value(json!({
#[rstest]
fn response_types_documented_block_variants(
#[values(
crate::ocr::types::OcrResponseFormat::Litellm,
crate::ocr::types::OcrResponseFormat::Native
)]
response_format: crate::ocr::types::OcrResponseFormat,
) {
let payload = json!({
"pages": [{
"type": "blocks",
"index": 0,
@ -654,21 +669,45 @@ mod tests {
"bottom_right_x": 0.7,
"bottom_right_y": 0.8
},
"title": "Totals"
"title": "Totals",
"description": "Invoice totals"
}
}
]
}]
}))
.unwrap();
let normalized = normalize_response("parse-v5.0", response).unwrap();
let blocks = normalized.pages[0].extra_fields["blocks"]
.as_array()
});
let normalized = CohereParseConfig
.transform_ocr_response(
"parse-v5.0",
&serde_json::to_vec(&payload).unwrap(),
response_format,
)
.unwrap();
assert_eq!(blocks[0]["text"]["content"], "hello");
assert_eq!(blocks[1]["image"]["category"], "logo");
assert_eq!(blocks[2]["table"]["type"], "html");
assert_eq!(blocks[2]["table"]["title"], "Totals");
assert_eq!(
normalized.pages[0].extra_fields["blocks"],
payload["pages"][0]["blocks"]
);
assert_eq!(normalized.pages[0].markdown, "");
assert_eq!(normalized.pages[0].index, 0);
assert_eq!(
normalized.usage_info.as_ref().unwrap().pages_processed,
Some(1)
);
match response_format {
crate::ocr::types::OcrResponseFormat::Litellm => {
assert!(normalized.provider_native_response.is_none());
}
crate::ocr::types::OcrResponseFormat::Native => {
assert_eq!(
normalized.provider_native_response.as_ref(),
payload.as_object()
);
}
}
assert_eq!(
normalized.into_json()["pages"][0]["blocks"],
payload["pages"][0]["blocks"]
);
}
#[test]

View file

@ -425,7 +425,9 @@ mod tests {
#[rstest]
#[case("table_format", json!("html"))]
#[case("table_format", json!("markdown"))]
#[case("confidence_scores_granularity", json!("word"))]
#[case("confidence_scores_granularity", json!("page"))]
#[case("document_annotation_prompt", json!("extract"))]
#[case("include_blocks", json!(true))]
#[case("id", json!("req-123"))]
@ -436,7 +438,9 @@ mod tests {
#[rstest]
#[case("pages", json!([0, 2]))]
#[case("pages", json!("0,2-4"))]
#[case("pages", Value::Null)]
#[case("include_image_base64", json!(true))]
#[case("include_image_base64", json!(false))]
#[case("image_limit", json!(2))]
#[case("image_min_size", json!(100))]
#[case("bbox_annotation_format", json!({"type":"json_schema"}))]
@ -445,19 +449,28 @@ mod tests {
#[case("extract_header", json!(true))]
#[case("extract_footer", json!(false))]
#[case("table_format", json!("html"))]
#[case("table_format", json!("markdown"))]
#[case("confidence_scores_granularity", json!("word"))]
#[case("confidence_scores_granularity", json!("page"))]
#[case("confidence_scores_granularity", json!("block"))]
#[case("include_blocks", json!(true))]
#[case("include_blocks", json!(false))]
#[case("id", json!("req-123"))]
fn request_mapping_matches_python(#[case] name: &str, #[case] value: Value) {
let params: OpaqueParams = serde_json::from_value(json!({name: value.clone()})).unwrap();
fn request_mapping_preserves_supplied_options(#[case] name: &str, #[case] value: Value) {
let arguments = serde_json::from_value(json!({name: value.clone()})).unwrap();
let params = MistralOCRConfig
.map_ocr_params(&arguments, "model")
.unwrap();
let result = serde_json::to_value(
MistralOCRConfig
.transform_ocr_request("model", document(), &params, &[])
.unwrap(),
)
.unwrap();
assert_eq!(result["model"], "model");
assert_eq!(result[name], value);
assert_eq!(
result,
json!({"model":"model", "document":document(), name:value})
);
}
#[rstest]
@ -504,30 +517,25 @@ mod tests {
#[rstest]
fn transform_ocr_response_preserves_blocks_and_confidence_scores() {
let response: MistralOcrResponse = serde_json::from_value(json!({
"pages":[{
"index":0,
"markdown":"hello",
"images":[{"id":"img-0","image_base64":"data:image/png;base64,AA=="}],
"dimensions":{"width":612,"height":792,"dpi":72},
"blocks":[{"type":"title","bbox":{"x":1},"confidence_scores":{"mean":0.98}}],
"confidence_scores":{"average_page_confidence_score":0.99,"minimum_page_confidence_score":0.97}
}],
"model":"returned-model",
"document_annotation":"{\"language\":\"en\"}",
"usage_info":{"pages_processed":1}
}))
.unwrap();
let payload = json!({
"pages":[{
"index":0,
"markdown":"hello",
"images":[{"id":"img-0","image_base64":"data:image/png;base64,AA=="}],
"dimensions":{"width":612,"height":792,"dpi":72},
"blocks":[{"type":"title","bbox":{"x":1},"confidence_scores":{"mean":0.98}}],
"confidence_scores":{"average_page_confidence_score":0.99,"minimum_page_confidence_score":0.97}
}],
"model":"returned-model",
"document_annotation":"{\"language\":\"en\"}",
"usage_info":{"pages_processed":1}
});
let response: MistralOcrResponse = serde_json::from_value(payload.clone()).unwrap();
let result = normalize_response("model", response).unwrap().into_json();
assert_eq!(result["pages"][0]["blocks"][0]["type"], "title");
assert_eq!(result["pages"][0]["blocks"][0]["bbox"]["x"], 1);
assert_eq!(result["pages"][0]["blocks"], payload["pages"][0]["blocks"]);
assert_eq!(
result["pages"][0]["blocks"][0]["confidence_scores"]["mean"],
0.98
);
assert_eq!(
result["pages"][0]["confidence_scores"]["average_page_confidence_score"],
0.99
result["pages"][0]["confidence_scores"],
payload["pages"][0]["confidence_scores"]
);
assert_eq!(result["pages"][0]["images"][0]["id"], "img-0");
assert_eq!(result["pages"][0]["dimensions"]["dpi"], 72);

View file

@ -393,6 +393,13 @@ mod tests {
#[rstest]
#[case("reducto/parse-legacy", OcrConfigKind::ReductoLegacy)]
#[case("reducto/future-parse-model", OcrConfigKind::ReductoV3)]
#[case("azure_ai/Cohere-parse-v5", OcrConfigKind::AzureCohere)]
#[case("azure_ai/cohere-parse-v5", OcrConfigKind::AzureCohere)]
#[case("azure_ai/cohere/parse-v5", OcrConfigKind::AzureCohere)]
#[case("azure_ai/invoice-parser", OcrConfigKind::AzureAi)]
#[case("azure_ai/parse-v5", OcrConfigKind::AzureAi)]
#[case("azure_ai/mistral-ocr-4-0", OcrConfigKind::AzureAi)]
#[case("azure_ai/mistral-document-ai-2512", OcrConfigKind::AzureAi)]
#[case(
"azure_ai/doc-intelligence/prebuilt-layout",
OcrConfigKind::AzureDocumentIntelligence

View file

@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use rstest::rstest;
use serde_json::{Value, json};
use super::test_support::{MockResponse, mock_server, perform_ocr, wire_request};
@ -48,33 +49,87 @@ async fn facade_maps_pages_features_and_url_document() {
);
}
#[rstest]
#[case(json!({"pages":[true]}), crate::ocr::Error::Pages("expected only integers or only strings".into()))]
#[case(json!({"pages":[1,"2"]}), crate::ocr::Error::Pages("expected only integers or only strings".into()))]
#[case(json!({"pages":[-1]}), crate::ocr::Error::Pages("negative page index".into()))]
#[case(json!({"pages":"1&&features=bad"}), crate::ocr::Error::Pages("invalid native page range".into()))]
#[case(json!({"features":"languages&pages=1"}), crate::ocr::Error::Features)]
#[case(json!({"req_format":"azure"}), crate::ocr::Error::RequestFormat)]
#[tokio::test]
async fn rejects_invalid_pages_features_and_format() {
for options in [
json!({"pages":[true]}),
json!({"pages":[1,"2"]}),
json!({"pages":[-1]}),
json!({"pages":"1&&features=bad"}),
json!({"features":"languages&pages=1"}),
json!({"req_format":"azure"}),
] {
let result = decode_request(OcrWireRequest {
model: "azure_ai/doc-intelligence/prebuilt-read".into(),
document: json!({"type":"document_url","document_url":"https://example.com/a.pdf"}),
api_key: Some("key".into()),
api_base: Some("http://127.0.0.1:1".into()),
custom_llm_provider: None,
extra_headers: None,
optional_params: options.as_object().unwrap().clone(),
input_sources: Default::default(),
timeout_seconds: None,
});
let rejected = match result {
Ok(request) => perform_ocr(request).await.is_err(),
Err(_) => true,
};
assert!(rejected, "accepted {options}");
async fn rejects_invalid_pages_features_and_format(
#[case] options: Value,
#[case] expected: super::Error,
) {
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({}))]).await;
let result = decode_request(OcrWireRequest {
model: "azure_ai/doc-intelligence/prebuilt-read".into(),
document: json!({"type":"document_url","document_url":"https://example.com/a.pdf"}),
api_key: Some("key".into()),
api_base: Some(base),
custom_llm_provider: None,
extra_headers: None,
optional_params: options.as_object().unwrap().clone(),
input_sources: Default::default(),
timeout_seconds: Some(2.0),
});
let result = match result {
Ok(request) => perform_ocr(request).await,
Err(error) => Err(error),
};
server.abort();
let _ = server.await;
assert!(
seen.lock().unwrap().is_empty(),
"sent invalid options: {options}"
);
let error = result.unwrap_err();
assert_eq!(
std::mem::discriminant(&error),
std::mem::discriminant(&expected)
);
assert_eq!(error.http_status_code(), Some(400));
assert_eq!(error.to_string(), expected.to_string());
}
#[rstest]
#[case(json!({}))]
#[case(json!({"req_format":"litellm"}))]
#[tokio::test]
async fn missing_native_fields_keep_page_text_without_retaining_raw_response(
#[case] options: Value,
) {
let operation = json!({
"status":"succeeded",
"analyzeResult":{"pages":[{"pageNumber":1,"lines":[{"content":"hello"}]}]}
});
let (base, seen, server) = mock_server(vec![MockResponse::json(operation)]).await;
let response = perform_ocr(wire_request(
"azure_ai/doc-intelligence/prebuilt-read",
&base,
options,
))
.await
.unwrap();
server.await.unwrap();
assert_eq!(response.pages.len(), 1);
assert_eq!(response.pages[0].index, 0);
assert_eq!(response.pages[0].markdown, "hello");
assert_eq!(response.provider_native_response, None);
let serialized = response.into_json();
assert_eq!(serialized.get("content"), Some(&Value::Null));
assert_eq!(serialized.get("tables"), Some(&Value::Null));
assert_eq!(serialized.get("keyValuePairs"), Some(&Value::Null));
let requests = seen.lock().unwrap();
assert_eq!(requests.len(), 1);
let target = requests[0].split_whitespace().nth(1).unwrap();
let url = format!("{base}{target}");
for field in ["pages", "features", "req_format"] {
assert_eq!(query_value(&url, field), None);
}
let body: Value = serde_json::from_str(requests[0].split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body, json!({"base64Source":"YWJj"}));
}
#[tokio::test]

View file

@ -70,13 +70,26 @@ async fn request_mapping_matches_python(
#[case("parse-v3")]
#[case("parse-legacy")]
#[tokio::test]
async fn data_uri_upload_preserves_multipart_headers(#[case] model: &str) {
async fn data_uri_upload_preserves_multipart_headers(
#[case] model: &str,
#[values("application/pdf", "image/png")] mime_type: &str,
) {
let (base, seen, server) = mock_server(vec![
MockResponse::json(json!({"file_id":"reducto://uploaded.pdf"})),
MockResponse::json(json!({"result":{"chunks":[{"content":"hello"}]}})),
])
.await;
let mut request = wire_request(&format!("reducto/{model}"), &base, json!({}));
let document = if mime_type.starts_with("image/") {
json!({"type":"image_url","image_url":format!("data:{mime_type};base64,YWJj")})
} else {
json!({"type":"document_url","document_url":format!("data:{mime_type};base64,YWJj")})
};
let mut request = super::LiteLLMOcrRequest {
document: serde_json::from_value::<super::OcrDocument>(document)
.unwrap()
.into(),
..wire_request(&format!("reducto/{model}"), &base, json!({}))
};
request.transport.extra_headers = vec![
("Content-Type".into(), "application/json".into()),
("X-Trace".into(), "upload-test".into()),
@ -94,9 +107,26 @@ async fn data_uri_upload_preserves_multipart_headers(#[case] model: &str) {
.contains("content-type: multipart/form-data; boundary=")
);
assert!(requests[0].contains("x-trace: upload-test"));
assert!(requests[0].contains("application/pdf"));
assert!(requests[0].contains("abc"));
let multipart = requests[0].split_once("\r\n\r\n").unwrap().1;
assert!(multipart.contains(&format!("Content-Type: {mime_type}\r\n")));
assert!(multipart.contains("\r\n\r\nabc\r\n--"));
assert!(requests[1].starts_with("POST /parse "));
let source_field = if model == "parse-legacy" {
"document_url"
} else {
"input"
};
assert_eq!(
request_body(&requests[1]),
json!({source_field:"reducto://uploaded.pdf"})
);
for request in requests.iter() {
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer test-key\r\n")
);
}
}
struct ParseBoundary {
@ -168,17 +198,37 @@ async fn upload_failure_stops_before_parse() {
}
#[rstest]
#[case("https://example.com/a.pdf")]
#[case("reducto://")]
#[case("data:application/pdf;base64")]
#[case("data:application/pdf;base64,INVALID!")]
#[case("https://example.com/a.pdf", crate::ocr::Error::ReductoSource)]
#[case("reducto://", crate::ocr::Error::RequestField { path: "document file id".into() })]
#[case("data:application/pdf;base64", crate::ocr::Error::InvalidDataUri)]
#[case(
"data:application/pdf;base64,INVALID!",
crate::ocr::Error::InvalidDataUri
)]
#[tokio::test]
async fn rejects_invalid_document_sources_before_network(#[case] source: &str) {
async fn rejects_invalid_document_sources_before_network(
#[case] source: &str,
#[case] expected: super::Error,
) {
let (base, seen, server) = mock_server(vec![MockResponse::json(json!({}))]).await;
let request = super::test_support::with_source(
wire_request("reducto/parse-v3", "http://127.0.0.1:1", json!({})),
wire_request("reducto/parse-v3", &base, json!({})),
source,
);
assert!(perform_ocr(request).await.is_err());
let result = perform_ocr(request).await;
server.abort();
let _ = server.await;
assert!(
seen.lock().unwrap().is_empty(),
"sent invalid source: {source}"
);
let error = result.unwrap_err();
assert_eq!(
std::mem::discriminant(&error),
std::mem::discriminant(&expected)
);
assert_eq!(error.http_status_code(), Some(400));
assert_eq!(error.to_string(), expected.to_string());
}
#[test]