vectorcraft/crates/mcp/src/tests_fileio.rs

125 lines
5.6 KiB
Rust

//! File I/O through MCP: every readable format opens headless, and `export` sends one
//! `document.export` call whatever the backend.
use std::io::{BufRead, BufReader, Write};
use serde_json::{Value, json};
use crate::{Backend, Headless, Remote, call_tool, tool_definitions};
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().to_string()
}
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle.
fn two_boards() -> Headless {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap();
h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap();
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
h
}
#[test]
fn headless_opens_pdf_ai_and_templates() {
let mut h = two_boards();
let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap();
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
for name in ["x.pdf", "x.ai"] {
let path = tmp(name);
std::fs::write(&path, &pdf).unwrap();
let r = text(&call_tool(&mut h, "open_file", &json!({"path": path})));
assert_eq!(r["title"], name);
assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page");
}
let ait = tmp("x.ait");
std::fs::write(&ait, &pdf).unwrap();
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
let tpl = tmp("t.vectorcraft");
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap();
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
}
#[test]
fn export_tool_lists_engine_formats_and_options() {
let tools = tool_definitions();
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
let props = &export["inputSchema"]["properties"];
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
}
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["description"].as_str().unwrap().contains(".ait"));
}
/// A fake app that records `engine.execute` calls and answers each with an empty object.
fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut seen = vec![];
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap();
seen.push(msg);
}
seen
});
(addr, h)
}
#[test]
fn export_is_the_same_call_in_the_app_and_headless() {
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}});
let (addr, app) = recording_app();
let mut remote = Remote::connect(&addr).unwrap();
assert!(!call_tool(&mut remote, "export", &args).is_error);
drop(remote);
let seen = app.join().unwrap();
assert_eq!(seen[0]["method"], "engine.execute");
assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}}));
// Headless runs that very call: artboard 1 (40 pt wide) at scale 2.
let mut h = two_boards();
let r = text(&call_tool(&mut h, "export", &args));
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width");
let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap();
assert_eq!(direct["dataBase64"], r["dataBase64"]);
}
#[test]
fn exporting_native_keeps_the_document_path() {
let mut h = two_boards();
let doc = tmp("keep.vectorcraft");
h.call("app.save", json!({"path": doc})).unwrap();
let copy = tmp("copy.vectorcraft");
let r = text(&call_tool(&mut h, "export", &json!({"path": copy})));
assert_eq!(r["format"], "vectorcraft");
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
// PDF range through the tool: one page.
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"})));
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(pdf_pages(&pdf), 1);
}
fn pdf_pages(pdf: &[u8]) -> usize {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
.unwrap();
h.session.doc().unwrap().doc.artboards.len()
}