120 lines
6.0 KiB
Rust
120 lines
6.0 KiB
Rust
//! An agent builds a small deck entirely through MCP and checks its work only through MCP.
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{Headless, Server};
|
|
|
|
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
|
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
|
let out = s.handle_line(&line).expect("a response");
|
|
serde_json::from_str(&out).expect("json")
|
|
}
|
|
|
|
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
|
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
|
|
let res = &r["result"];
|
|
assert_eq!(res["isError"], false, "{name} failed: {res}");
|
|
let text =
|
|
res["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).unwrap_or_default();
|
|
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_and_tool_list() {
|
|
let mut s = Server::new(Box::new(Headless::new()));
|
|
let r = call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
|
|
assert_eq!(r["result"]["serverInfo"]["name"], "deckcraft");
|
|
let tools = call(&mut s, 2, "tools/list", json!({}));
|
|
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
for n in ["run_command", "render_slide", "add_slide", "add_shape", "pointer", "inspect_slide"] {
|
|
assert!(names.contains(&n), "{n}");
|
|
}
|
|
// Garbage never panics.
|
|
assert!(s.handle_line("not json").is_some());
|
|
assert!(s.handle_line("{}").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn agent_builds_a_deck() {
|
|
let mut s = Server::new(Box::new(Headless::new()));
|
|
tool(&mut s, "new_presentation", json!({"theme": "Harbor"}));
|
|
let doc = tool(&mut s, "inspect_document", json!({}));
|
|
let title_id = {
|
|
let sl = tool(&mut s, "inspect_slide", json!({}));
|
|
sl["shapes"][0]["id"].as_u64().unwrap()
|
|
};
|
|
let _ = doc;
|
|
tool(&mut s, "set_text", json!({"id": title_id, "text": "Quarterly Review"}));
|
|
tool(&mut s, "add_slide", json!({"layout": "titleAndContent", "title": "Highlights", "body": "Revenue up\n\tNew markets\nCosts down"}));
|
|
let shape = tool(&mut s, "add_shape", json!({"preset": "roundRect", "x": 600, "y": 300, "w": 200, "h": 100, "text": "Goal", "fill": "accent2"}));
|
|
let sid = shape["id"].as_u64().unwrap();
|
|
tool(&mut s, "run_command", json!({"command": "shape.rotate", "params": {"id": sid, "deg": 10}}));
|
|
// Drag the shape with the pointer.
|
|
tool(
|
|
&mut s,
|
|
"pointer",
|
|
json!({"events": [{"kind": "down", "x": 700, "y": 350}, {"kind": "drag", "x": 650, "y": 330}, {"kind": "up", "x": 650, "y": 330}]}),
|
|
);
|
|
tool(&mut s, "insert_chart", json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]}));
|
|
let sl = tool(&mut s, "inspect_slide", json!({}));
|
|
assert_eq!(sl["title"], "Highlights");
|
|
let shapes = sl["shapes"].as_array().unwrap();
|
|
let goal = shapes.iter().find(|x| x["id"].as_u64() == Some(sid)).unwrap();
|
|
assert_eq!(goal["text"], "Goal");
|
|
assert_eq!(goal["rotation"], 10.0);
|
|
assert!(goal["box"][0].as_f64().unwrap() < 600.0, "moved left: {goal}");
|
|
assert!(shapes.iter().any(|x| x["kind"] == "chart"));
|
|
let doc = tool(&mut s, "inspect_document", json!({}));
|
|
assert_eq!(doc["slides"].as_array().unwrap().len(), 2);
|
|
assert_eq!(doc["theme"], "Harbor");
|
|
// Look at it.
|
|
let r = call(&mut s, 10, "tools/call", json!({"name": "render_slide", "arguments": {"scale": 0.25}}));
|
|
assert_eq!(r["result"]["content"][0]["type"], "image");
|
|
// Keyboard: select the shape, delete it, undo.
|
|
tool(&mut s, "select", json!({"ids": [sid]}));
|
|
tool(&mut s, "key", json!({"key": "Delete"}));
|
|
let n1 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len();
|
|
tool(&mut s, "key", json!({"key": "z", "mods": {"cmd": true}}));
|
|
let n2 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len();
|
|
assert_eq!(n2, n1 + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn errors_are_reported_not_panics() {
|
|
let mut s = Server::new(Box::new(Headless::new()));
|
|
let r = call(&mut s, 1, "tools/call", json!({"name": "run_command", "arguments": {"command": "no.such"}}));
|
|
assert_eq!(r["result"]["isError"], true);
|
|
let r = call(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
|
|
assert_eq!(r["result"]["isError"], true);
|
|
let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}}));
|
|
assert_eq!(r["result"]["isError"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn export_tool_lists_pdf_and_its_options() {
|
|
let mut s = Server::new(Box::new(Headless::new()));
|
|
let tools = call(&mut s, 1, "tools/list", json!({}));
|
|
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
|
assert!(export["description"].as_str().unwrap().contains("pdf"), "{export}");
|
|
let props = &export["inputSchema"]["properties"];
|
|
let formats = props["format"]["enum"].as_array().unwrap();
|
|
for f in ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"] {
|
|
assert!(formats.iter().any(|x| x == f), "format {f}: {export}");
|
|
}
|
|
assert_eq!(props["layout"]["enum"], json!(["slides", "notes", "handouts"]));
|
|
assert_eq!(props["perPage"]["enum"], json!([1, 2, 3, 4, 6, 9]));
|
|
for p in ["dpi", "slides", "includeHidden", "textLayer", "frame"] {
|
|
assert!(props.get(p).is_some(), "{p}: {export}");
|
|
}
|
|
// What the schema describes is accepted.
|
|
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-export-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
tool(&mut s, "new_presentation", json!({}));
|
|
for layout in ["slides", "notes", "handouts"] {
|
|
let path = dir.join(format!("{layout}.pdf"));
|
|
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "format": "pdf", "layout": layout, "perPage": 3}));
|
|
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"), "{layout}");
|
|
}
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|