226 lines
11 KiB
Rust
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);
|
|
}
|