Files
effectcraft/crates/automation/src/tests.rs
T
bflatasticandClaude Opus 5.5 e43bf66775 M13.36: face tracking trained model — MediaPipe Face Landmarker on a pure-Rust TFLite interpreter
- effectcraft-segment: a FaceModel interface (find a face in a region, follow it; a Topology
  maps the model's points to the tracker's landmarks and outline), a TensorFlow Lite reader and
  interpreter (float ops, float16 weights folded; matches ai-edge-litert to ~1e-6), and
  MediaPipe Face Landmarker (Google, Apache-2.0: BlazeFace + Face Mesh V2, 3.8 MB, pinned
  SHA-256). The registry gains a task and authors per model; installed weights get a NOTICE.
- effectcraft-track: FaceTracker::new_with uses the model, falls back to the classical fitter
  when it finds no face; chin and jaw come from the outline either way so measurements agree.
- Engine: roto_models becomes models (both tasks); face.models / face.model.* commands,
  Settings ▸ Face Tracking, `wait` on select/install/download for scripts; track.mask reports
  faceModel.
- Credits: ATTRIBUTION.md lists the optional models and the Apache-2.0 specs the code follows.
- Docs: README, ROADMAP (G7), gaps, parity (TRK-3 score unchanged pending G1), agents,
  preferences, architecture, segment README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:39:45 +07:00

412 lines
21 KiB
Rust

//! MCP protocol round-trips, in-process (headless) and against a fake control channel (bridge).
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use serde_json::{Value, json};
use crate::{Backend, McpServer, Session, base64, png_size};
fn server() -> McpServer {
McpServer::new(Backend::headless(Session::default()))
}
/// Send a request, return its `result` (panics on a JSON-RPC error).
fn rpc(s: &mut McpServer, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply: Value = serde_json::from_str(&s.handle_line(&line).expect("reply")).unwrap();
assert_eq!(reply["jsonrpc"], "2.0");
assert_eq!(reply["id"], id);
assert!(reply.get("error").is_none(), "{method}: {reply}");
reply["result"].clone()
}
/// Call a tool; returns (content, isError).
fn call(s: &mut McpServer, name: &str, args: Value) -> (Vec<Value>, bool) {
let r = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args}));
(r["content"].as_array().unwrap().clone(), r["isError"].as_bool().unwrap())
}
/// Call a tool that returns JSON text; panics if it failed.
fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value {
let (c, err) = call(s, name, args.clone());
assert!(!err, "{name} {args}: {c:?}");
assert_eq!(c[0]["type"], "text");
serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap()
}
#[test]
fn initialize_and_list_tools() {
let mut s = server();
let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}));
assert_eq!(r["protocolVersion"], "2025-03-26");
assert_eq!(r["serverInfo"]["name"], "effectcraft");
assert!(r["capabilities"]["tools"].is_object());
// Unknown revision: we answer with our latest.
let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(r["protocolVersion"], crate::server::PROTOCOL_VERSIONS[0]);
// Notifications get no reply.
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert_eq!(rpc(&mut s, 3, "ping", json!({})), json!({}));
let tools = rpc(&mut s, 4, "tools/list", json!({}))["tools"].as_array().unwrap().clone();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for want in [
"list_commands",
"execute_command",
"run_script",
"get_project",
"get_comp",
"get_layer",
"get_property",
"set_property",
"add_keyframe",
"render_frame",
"open_project",
"save_project",
"undo",
"redo",
] {
assert!(names.contains(&want), "missing {want}");
}
assert!(!names.contains(&"screenshot"), "bridge-only tools are hidden headless");
for t in &tools {
assert!(t["description"].as_str().unwrap().len() > 20);
assert_eq!(t["inputSchema"]["type"], "object");
}
}
#[test]
fn protocol_errors() {
let mut s = server();
let r: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
assert_eq!(r["error"]["code"], -32700);
let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":5,"method":"nope"}"#).unwrap()).unwrap();
assert_eq!(r["error"]["code"], -32601);
let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"screenshot"}}"#).unwrap()).unwrap();
assert_eq!(r["error"]["code"], -32602);
// Batches.
let r: Value =
serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":7,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap()).unwrap();
assert_eq!(r.as_array().unwrap().len(), 1);
// Tool failures are in-band.
let (c, err) = call(&mut s, "execute_command", json!({"command": "no.such"}));
assert!(err);
assert!(c[0]["text"].as_str().unwrap().contains("unknown command"));
let (_, err) = call(&mut s, "render_frame", json!({}));
assert!(err, "no comp yet");
// Unknown params are rejected with the accepted keys, not silently ignored.
let (c, err) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"nmae": "X"}}));
assert!(err);
assert!(c[0]["text"].as_str().unwrap().contains("accepted: name, width"), "{c:?}");
}
#[test]
fn headless_workflow() {
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new"}));
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "layer.newSolid"));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main", "width": 320, "height": 180, "duration": 4}}));
let l =
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": "{\"color\":\"#ff0000\",\"width\":100,\"height\":100}"}))["layer"]
.clone();
assert!(l.is_u64());
let comp = call_json(&mut s, "get_comp", json!({"comp": "Main"}));
assert_eq!(comp["width"], 320);
assert_eq!(comp["layers"][0]["id"], l);
// Static set + read back.
let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/opacity", "value": 50}));
assert_eq!(p["value"], 50.0);
assert_eq!(call_json(&mut s, "get_property", json!({"layer": "#1", "path": "transform/opacity"}))["value"], 50.0);
// Keyframes with easing.
let p = call_json(
&mut s,
"add_keyframe",
json!({"layer": l, "path": "transform/position", "keys": [{"time": 0, "value": [0, 90]}, {"time": 2, "value": [320, 90]}], "interpolation": "easyEase"}),
);
assert_eq!(p["keys"].as_array().unwrap().len(), 2);
assert_eq!(p["keys"][0]["out"], "Bezier");
let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0}));
assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}");
// Expression via set_property.
let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"}));
assert_eq!(p["expression"], "time * 90");
// Property tree carries paths; flat mode too.
let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true}));
let props = tree["properties"].as_array().unwrap();
assert!(props.iter().any(|p| p["path"] == "transform/position" && p["keys"] == 2));
// Render: an inline PNG at the requested size.
let (c, err) = call(&mut s, "render_frame", json!({"time": 1.0, "max_side": 160}));
assert!(!err, "{c:?}");
assert_eq!(c[0]["type"], "image");
assert_eq!(c[0]["mimeType"], "image/png");
let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap();
assert_eq!(png_size(&png), Some((160, 90)));
let info: Value = serde_json::from_str(c[1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["width"], 160);
// Undo / redo.
let before = call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len();
let u = call_json(&mut s, "undo", json!({"steps": 2}));
assert_eq!(u["steps"], 2);
assert_eq!(u["undoStack"].as_array().unwrap().len(), before - 2);
call_json(&mut s, "redo", json!({"steps": 2}));
assert_eq!(call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len(), before);
// Save + reopen.
let dir = std::env::temp_dir().join(format!("ec-mcp-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.ecproj").to_string_lossy().into_owned();
let saved = call_json(&mut s, "save_project", json!({"path": path}));
assert_eq!(saved["dirty"], false);
call_json(&mut s, "open_project", json!({"new": true}));
assert!(call(&mut s, "get_comp", json!({})).1, "empty project has no comp");
let proj = call_json(&mut s, "open_project", json!({"path": path}));
assert_eq!(proj["items"].as_array().unwrap().len(), 3, "comp + Solids folder + solid: {proj}");
let p = call_json(&mut s, "get_property", json!({"comp": "Main", "layer": "#1", "path": "transform/position"}));
assert_eq!(p["keys"].as_array().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn stdio_loop() {
let mut s = server();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "open_project", "arguments": {"demo": true}}}),
]
.iter()
.map(|v| v.to_string() + "\n")
.collect::<String>();
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[1]["id"], 2);
assert_eq!(lines[1]["result"]["isError"], false);
}
/// A fake desktop control channel: answers `engine.execute` from a real session, and canned
/// `render.frame` / `ui.screenshot` replies.
fn fake_app() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let (stream, _) = l.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut session = Session::default();
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
let p = &msg["params"];
let reply = match msg["method"].as_str().unwrap() {
"engine.execute" => match session.execute(p["command"].as_str().unwrap(), p["params"].clone()) {
Ok(v) => json!({"ok": true, "result": v}),
Err(e) => json!({"ok": false, "error": e.to_string()}),
},
"render.frame" => {
let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap();
json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}})
}
"ui.screenshot" => {
let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap();
std::fs::write(p["path"].as_str().unwrap(), png).unwrap();
json!({"ok": true, "result": {"path": p["path"]}})
}
"ui.key" => json!({"ok": true, "result": null}),
m => json!({"ok": false, "error": format!("unknown method `{m}`")}),
};
let mut reply = reply;
reply["id"] = msg["id"].clone();
writeln!(out, "{reply}").unwrap();
}
});
port
}
#[test]
fn bridge_forwards_to_control_channel() {
let port = fake_app();
let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap());
let r = rpc(&mut s, 1, "initialize", json!({}));
assert!(r["serverInfo"]["title"].as_str().unwrap().contains("bridge"));
let names: Vec<String> =
rpc(&mut s, 2, "tools/list", json!({}))["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect();
assert!(names.iter().any(|n| n == "screenshot") && names.iter().any(|n| n == "ui_click"));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}}));
assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B");
let (c, err) = call(&mut s, "render_frame", json!({}));
assert!(!err, "{c:?}");
assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 2)));
let (c, err) = call(&mut s, "screenshot", json!({"max_side": 4}));
assert!(!err, "{c:?}");
assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 4)));
assert!(!call(&mut s, "ui_key", json!({"key": "Space"})).1);
let (c, err) = call(&mut s, "control", json!({"method": "ui.nope"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown method"));
}
#[test]
fn bridge_rejects_non_loopback() {
assert!(Backend::bridge("10.0.0.1:9877").is_err());
assert!(Backend::bridge("localhost:9877").is_ok());
}
#[test]
fn script_ui_and_history_tools() {
let mut sess = Session::default();
effectcraft_script::install(&mut sess);
let mut s = McpServer::new(Backend::headless(sess));
let code = "var d = new Window('dialog', 'Ask'); var g = d.add('group'); g.add('button', undefined, 'OK', {name: 'ok'}); \
if (d.show() == 1) app.project.items.addComp('Answered', 64, 64, 1, 1, 24); 'done'";
let r = call_json(&mut s, "run_script", json!({"code": code}));
assert_eq!(r["waiting"], json!(true), "{r}");
let list = call_json(&mut s, "script_ui", json!({}));
assert_eq!(list[0]["title"], "Ask");
let tree = call_json(&mut s, "script_ui", json!({"action": "get"}));
assert_eq!(tree["root"]["children"][0]["children"][0]["name"], "ok");
let r = call_json(&mut s, "script_ui", json!({"action": "click", "widget": "ok"}));
assert_eq!(r["result"], json!("done"), "{r}");
// History: undo the comp, make another, and jump back to the first on its branch.
call_json(&mut s, "undo", json!({}));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Other", "width": 64, "height": 64}}));
let h = call_json(&mut s, "history", json!({}));
let branch = h["states"].as_array().unwrap().iter().find(|n| n["depth"] == 1).unwrap()["index"].clone();
let h = call_json(&mut s, "history", json!({"goto": branch}));
let cur = h["current"].as_u64().unwrap() as usize;
assert_eq!(h["states"][cur]["depth"], 0, "the jumped-to state is now the working line");
assert!(call_json(&mut s, "get_project", json!({})).to_string().contains("Answered"));
}
#[test]
fn run_script_round_trip() {
let mut sess = Session::default();
effectcraft_script::install(&mut sess);
let mut s = McpServer::new(Backend::headless(sess));
let r = call_json(
&mut s,
"run_script",
json!({"code": "var c = app.project.items.addComp('Scripted', 320, 180, 1, 2, 24);\nc.layers.addText('Hi');\nwriteLn('made ' + c.name);\nc.numLayers"}),
);
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(r["result"], json!(1));
assert_eq!(r["output"], json!("made Scripted"));
// The edit is visible to the other tools.
let p = call_json(&mut s, "get_project", json!({}));
assert!(p.to_string().contains("Scripted"), "{p}");
// Errors come back with their line.
let r = call_json(&mut s, "run_script", json!({"code": "var a = 1;\nundefinedFn();", "name": "bad.jsx"}));
assert_eq!(r["ok"], json!(false));
assert_eq!(r["error"]["line"], json!(2));
assert_eq!(r["error"]["file"], json!("bad.jsx"));
// Without a scripting engine the command explains itself.
let mut bare = server();
let (c, err) = call(&mut bare, "run_script", json!({"code": "1"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}");
}
#[test]
fn puppet_rigging_over_mcp() {
let mut s = server();
let ex = |s: &mut McpServer, command: &str, params: Value| call_json(s, "execute_command", json!({"command": command, "params": params}));
ex(&mut s, "comp.new", json!({"name": "Rig", "width": 200, "height": 100, "duration": 2}));
let id = ex(&mut s, "layer.newSolid", json!({"width": 100, "height": 60, "color": "#3060c0"}))["layer"].clone();
let lead = ex(&mut s, "puppet.addPin", json!({"layer": id, "position": [10, 30]}))["pin"].clone();
let tail = ex(&mut s, "puppet.addPin", json!({"layer": id, "position": [90, 30]}))["pin"].clone();
let bend = ex(&mut s, "puppet.addPin", json!({"layer": id, "kind": "bend", "position": [50, 30]}))["pin"].clone();
// Turn and scale the Bend pin, select pins, make the tail trail the leader, rig the leader.
ex(&mut s, "puppet.setPin", json!({"layer": id, "pin": bend, "rotation": 45, "scale": 120}));
assert_eq!(ex(&mut s, "puppet.selectPins", json!({"layer": id, "pins": [lead, tail]}))["pins"], json!([lead, tail]));
assert_eq!(ex(&mut s, "puppet.follow", json!({"delay": 0.25}))["pins"], json!([{"pin": tail, "delay": 0.25}]));
let r = ex(&mut s, "paths.pointsFollowNulls", json!({"layer": id, "pins": [lead]}));
assert_eq!(r["nulls"].as_array().unwrap().len(), 1);
// The new commands are listed for agents.
let (c, _) = call(&mut s, "list_commands", json!({"filter": "puppet."}));
let text = c[0]["text"].as_str().unwrap();
assert!(text.contains("puppet.follow") && text.contains("puppet.selectPins"), "{text}");
}
/// The agent-interface audit (MCP side): every registered engine command is listed by
/// `list_commands` with a label, params doc and JSON Schema, described by `describe_command`,
/// and routed by `execute_command` (an unknown parameter is rejected by that very command
/// without running it).
#[test]
fn every_engine_command_is_reachable_over_mcp() {
let mut s = server();
let tools = rpc(&mut s, 1, "tools/list", json!({}))["tools"].as_array().unwrap().clone();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for want in ["list_commands", "describe_command", "execute_command", "add_effect", "list_effects", "get_state", "render_frame", "get_layer", "set_property"]
{
assert!(names.contains(&want), "{want}");
}
let listed = call_json(&mut s, "list_commands", json!({"schemas": true}));
let listed: std::collections::HashMap<&str, &Value> = listed.as_array().unwrap().iter().map(|c| (c["id"].as_str().unwrap(), c)).collect();
let specs = effectcraft_engine::command_specs();
assert_eq!(listed.len(), specs.len());
for spec in specs {
let c = listed.get(spec.id).unwrap_or_else(|| panic!("{} not listed", spec.id));
assert!(!spec.label.trim().is_empty(), "{}: no label", spec.id);
assert!(spec.params.trim_start().starts_with('{'), "{}: params doc {:?}", spec.id, spec.params);
assert_eq!(c["schema"]["type"], "object", "{}", spec.id);
assert!(c["schema"]["properties"].is_object(), "{}", spec.id);
let d = call_json(&mut s, "describe_command", json!({"command": spec.id}));
assert_eq!(d["schema"], c["schema"], "{}", spec.id);
let (content, err) = call(&mut s, "execute_command", json!({"command": spec.id, "params": {"__audit": 1}}));
let text = content[0]["text"].as_str().unwrap_or_default();
assert!(err && text.contains("unknown parameter") && text.contains(spec.id), "{}: {text}", spec.id);
}
}
#[test]
fn high_level_tools_add_effect_and_report_state() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 48, "duration": 1}}));
let l = call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"color": "#808080"}}))["layer"].clone();
let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian"}));
assert!(fx.as_array().unwrap().iter().any(|e| e["id"] == "ec.blur.gaussian" && e["gpu"].is_boolean()), "{fx}");
let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "Gaussian Blur", "values": {"blurriness": 7}}));
assert_eq!(r["path"], "effects/#1", "{r}");
assert!(r["params"].as_array().unwrap().iter().any(|p| p["path"] == "effects/#1/blurriness" && p["value"] == 7.0), "{r}");
// A second effect lands at #2.
let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "ec.color.levels"}));
assert_eq!(r["path"], "effects/#2", "{r}");
let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "effects/#1/blurriness"}));
assert_eq!(p["value"], 7.0, "{p}");
let st = call_json(&mut s, "get_state", json!({}));
assert!(st["state"].is_object() && st["app"]["commands"].as_u64().unwrap() > 500, "{st}");
assert!(st["app"]["effects"]["total"].as_u64().unwrap() > 200, "{st}");
assert!(st["app"]["parity"]["summary"].as_str().unwrap().contains("parity"), "{st}");
// Unknown effects fail cleanly.
let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
}
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
/// licence, chosen, refused when unknown.
#[test]
fn trained_models_over_mcp() {
let mut s = server();
for (prefix, id) in [("roto", "mobilesam"), ("face", "mediapipe-face")] {
let list = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.models"), "params": {}}));
let models = list["models"].as_array().unwrap();
assert!(models.iter().any(|m| m["id"] == id && m["licence"] == "Apache-2.0" && m["authors"].is_string() && m["installed"] == false), "{list}");
let r = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": id}}));
assert_eq!(r["model"], id);
let (c, err) = call(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": "nope"}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown model"), "{c:?}");
}
}