125 lines
5.6 KiB
Rust
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()
|
|
}
|