pdfcraft/crates/automation/tests/batch.rs

226 lines
11 KiB
Rust

use pdfcraft_automation::{Automation, Content};
use pdfcraft_engine::Session;
use serde_json::{Value, json};
fn result(auto: &mut Automation, args: Value) -> Value {
match auto.call("doc_batch", &args).unwrap().remove(0) {
Content::Json(value) => value,
_ => panic!("expected a report"),
}
}
fn pdf() -> Vec<u8> {
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
}
#[test]
fn batch_recurses_preserves_paths_continues_errors_and_never_replaces_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir_all(dir.path().join("input/output/sub")).unwrap();
std::fs::write(dir.path().join("input/a.txt"), "Hello").unwrap();
std::fs::write(dir.path().join("input/b.pdf"), b"broken").unwrap();
std::fs::write(dir.path().join("input/sub/a.txt"), "World").unwrap();
std::fs::write(dir.path().join("input/notes.bin"), b"ignore").unwrap();
std::fs::write(dir.path().join("input/output/sub/a.pdf"), b"precious").unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let args = json!({"paths":["input","input/a.txt"],"steps":["convert","optimize"],"out_dir":"input/output","recursive":true});
let report = result(&mut auto, args.clone());
assert_eq!(report["completed"], 2, "{report}");
assert_eq!(report["failed"], 1);
assert_eq!(report["skipped"], 1);
assert_eq!(std::fs::read(dir.path().join("input/output/sub/a.pdf")).unwrap(), b"precious");
assert_eq!(std::fs::read_to_string(dir.path().join("input/a.txt")).unwrap(), "Hello");
for name in ["a.pdf", "sub/a (2).pdf"] {
let bytes = std::fs::read(dir.path().join("input/output").join(name)).unwrap();
let mut session = Session::new();
let id = session.open(name, None, std::sync::Arc::new(bytes), None).unwrap();
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
}
assert!(!dir.path().join("input/output/b.pdf").exists());
assert!(auto.session().docs().is_empty());
let again = result(&mut auto, args);
assert_eq!(again["completed"], 2, "previous outputs must not become new inputs: {again}");
assert!(dir.path().join("input/output/a (2).pdf").exists());
}
#[test]
fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap();
std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap();
std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true}));
assert_eq!(report["failed"], 1);
assert_eq!(report["completed"], 0);
assert_eq!(report["skipped"], 1);
assert_eq!(report["stopped"], true);
assert!(!dir.path().join("out").exists());
let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"}));
assert_eq!(report["completed"], 1);
assert!(!dir.path().join("out/sub").exists());
assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64);
}
#[test]
fn invalid_batch_arguments_and_outside_paths_write_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
for change in [
json!({"steps":["convert","convert"]}),
json!({"steps":["optimize","convert"]}),
json!({"steps":[]}),
json!({"steps":["nope"]}),
json!({"recursive":"yes"}),
json!({"paths":[]}),
json!({"paths":["input","../outside"]}),
json!({"out_dir":"../outside"}),
json!({"out_dir":"input"}),
json!({"out_dir":"."}),
json!({"quality":0}),
json!({"quality":"80"}),
json!({"ppi":2000}),
json!({"steps":["ocr"],"language":"not-a-language"}),
json!({"steps":["ocr"],"dpi":1}),
] {
let mut args = json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"});
args.as_object_mut().unwrap().extend(change.as_object().unwrap().clone());
assert!(auto.call("doc_batch", &args).is_err(), "{args}");
assert!(!dir.path().join("out").exists(), "invalid arguments must be refused before writing");
}
}
#[test]
fn explicitly_selected_subfolder_is_processed_without_recursive() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert!(dir.path().join("out/page.pdf").exists());
}
#[test]
fn explicit_file_can_write_a_numbered_copy_beside_its_source() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf());
assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf());
}
#[cfg(unix)]
#[test]
fn non_utf8_discovered_names_are_reported_without_panicking() {
use std::os::unix::ffi::OsStrExt;
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
if let Err(error) = std::fs::write(&invalid, pdf()) {
// APFS rejects this filename before batch discovery can see it. Keep the
// discovery assertions on filesystems that allow it; no other I/O error
// is an unsupported fixture.
#[cfg(target_os = "macos")]
if error.raw_os_error() == Some(92) {
assert!(!invalid.exists());
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
assert!(!dir.path().join("out").exists());
let valid = dir.path().join("input/valid-λ.pdf");
std::fs::write(&valid, pdf()).unwrap();
assert_eq!(std::fs::read(&valid).unwrap(), pdf());
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), pdf());
eprintln!(
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
);
return;
}
panic!("could not create the non-UTF-8 fixture: {error}");
}
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
assert_eq!(report["failed"], 1);
assert!(report["files"][0]["error"].as_str().unwrap().contains("UTF-8"));
}
#[cfg(unix)]
#[test]
fn directory_links_are_not_followed_and_output_links_cannot_escape() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
std::fs::create_dir(dir.path().join("outside")).unwrap();
std::fs::create_dir(dir.path().join("out")).unwrap();
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
std::os::unix::fs::symlink(dir.path().join("input"), dir.path().join("input/loop")).unwrap();
std::os::unix::fs::symlink(dir.path().join("outside"), dir.path().join("out/sub")).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out","recursive":true}));
assert_eq!(report["skipped"], 1);
assert_eq!(report["failed"], 1);
assert_eq!(std::fs::read_dir(dir.path().join("outside")).unwrap().count(), 0);
}
#[test]
fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["ocr","optimize"],"out_dir":"out"}));
if pdfcraft_engine::ocr::available() {
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["files"][0]["steps"][0]["skipped_pages"], 1);
assert!(dir.path().join("out/page.pdf").exists());
} else {
assert_eq!(report["failed"], 1, "{report}");
assert!(report["files"][0]["error"].as_str().unwrap().contains("model"), "{report}");
assert!(!dir.path().join("out/page.pdf").exists());
}
}
#[test]
fn batch_output_collisions_are_safe_under_concurrent_writers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap();
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
let handles: Vec<_> = (0..2)
.map(|_| {
scope.spawn(|| {
let mut auto = Automation::new().with_root(dir.path()).unwrap();
barrier.wait();
result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"out"}))
})
})
.collect();
let reports: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(reports.iter().all(|r| r["completed"] == 1), "{reports:?}");
assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]);
});
for name in ["page.pdf", "page (2).pdf"] {
assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), pdf());
}
assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain");
}
#[cfg(feature = "mcp")]
#[test]
fn mcp_batch_errors_keep_the_structured_per_file_report() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("broken.pdf"), b"broken").unwrap();
let input = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"doc_batch","arguments":{"paths":["broken.pdf"],"steps":["optimize"],"out_dir":"out"}}}).to_string() + "\n";
let mut out = Vec::new();
pdfcraft_automation::mcp::McpServer::new(Automation::new().with_root(dir.path()).unwrap()).serve(input.as_bytes(), &mut out).unwrap();
let report: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(report["result"]["isError"], true, "{report}");
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
}