lightcraft/crates/mcp/tests/e2e.rs

156 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! M0.9 acceptance: drive LightCraft over MCP (stdio framing), set exposure and render an image.
//! Runs against the headless backend directly and through the TCP control-channel transport
//! (`Remote`) to a stand-in control server.
use std::io::{BufRead, BufReader, Cursor, Write};
use std::net::TcpListener;
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
use serde_json::{Value, json};
/// Feed a whole session through `Server::serve` and return the replies by id.
fn session(server: &mut Server, msgs: &[Value]) -> Vec<Value> {
let input: String = msgs.iter().map(|m| format!("{m}\n")).collect();
let mut out = Vec::new();
server.serve(Cursor::new(input), &mut out).unwrap();
String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
fn call(id: u64, name: &str, args: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})
}
/// Mean sRGB value of the PNG in an MCP image result.
fn mean_of(result: &Value) -> (f64, u32, u32) {
let img = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("no image in {result}"));
assert_eq!(img["mimeType"], "image/png");
let png = base64_decode(img["data"].as_str().unwrap()).unwrap();
assert_eq!(&png[1..4], b"PNG");
let d = lightcraft_codecs::decode(&png, Default::default()).unwrap();
let rgba = d.to_srgb8();
let sum: u64 = rgba.data.iter().map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64).sum();
(sum as f64 / (3 * rgba.data.len()) as f64, d.width, d.height)
}
fn exposure_roundtrip(server: &mut Server) {
let replies = session(
server,
&[
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
call(3, "query_photos", json!({"limit": 1})),
],
);
assert_eq!(replies.len(), 3, "notifications get no reply");
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lightcraft");
let tools: Vec<&str> = replies[1]["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(tools.contains(&"set_develop") && tools.contains(&"render_photo") && tools.contains(&"cmd_develop_set"));
let id = replies[2]["result"]["structuredContent"]["photos"][0]["id"].as_u64().expect("a photo");
let replies = session(
server,
&[
call(10, "render_photo", json!({"id": id, "size": 256})),
call(11, "set_develop", json!({"id": id, "values": {"light.exposure": 1.5}})),
call(12, "get_develop", json!({"id": id})),
call(13, "render_photo", json!({"id": id, "size": 256})),
// The generated per-command tool works too: exposure -1.5 via `cmd_develop_set`.
call(14, "cmd_develop_set", json!({"control": "light.exposure", "value": -1.5})),
call(15, "render_photo", json!({"size": 256})),
],
);
for r in &replies {
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{r}");
}
assert_eq!(replies[1]["result"]["structuredContent"]["controls"][0]["value"], 1.5);
assert_eq!(replies[2]["result"]["structuredContent"]["light"]["exposure"], 1.5);
let (before, w, h) = mean_of(&replies[0]["result"]);
let (brighter, w2, h2) = mean_of(&replies[3]["result"]);
let (darker, _, _) = mean_of(&replies[5]["result"]);
assert_eq!((w, h), (w2, h2));
assert!(w.max(h) <= 256 && w.max(h) >= 200, "{w}x{h}");
println!("mean sRGB: exposure 0 → {before:.1}, +1.5 → {brighter:.1}, -1.5 → {darker:.1} ({w}×{h})");
assert!(brighter > before + 15.0, "exposure +1.5 should brighten: {before} → {brighter}");
assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}");
}
#[test]
fn headless_set_exposure_and_render() {
exposure_roundtrip(&mut Server::new(Box::new(Headless::demo())));
}
#[test]
fn import_render_export_real_file() {
let dir = std::env::temp_dir().join(format!("lightcraft-mcp-e2e-{}", std::process::id()));
std::fs::create_dir_all(dir.join("sub")).unwrap();
// A synthetic gradient PNG to import.
let img = lightcraft_raster::Rgba8::from_fn(96, 64, |x, y| [(x * 2) as u8, (y * 3) as u8, 128, 255]);
lightcraft_mcp::write_image(&dir.join("sub/gradient.png"), &img, 90).unwrap();
let out = dir.join("out.jpg");
let mut server = Server::new(Box::new(Headless::default()));
let replies = session(
&mut server,
&[
call(1, "import", json!({"paths": [dir.to_string_lossy()]})),
call(2, "set_develop", json!({"values": {"light.exposure": 0.5}})),
call(3, "render_photo", json!({"format": "jpeg", "size": 64})),
call(4, "export", json!({"path": out.to_string_lossy(), "longEdge": 48})),
],
);
for r in &replies {
assert!(r["result"]["isError"] == false, "{r}");
}
assert_eq!(replies[0]["result"]["structuredContent"]["imported"].as_array().unwrap().len(), 1);
assert_eq!(replies[2]["result"]["content"][0]["mimeType"], "image/jpeg");
let jpg = std::fs::read(&out).unwrap();
assert_eq!(&jpg[..2], &[0xff, 0xd8]);
let d = lightcraft_codecs::decode(&jpg, Default::default()).unwrap();
assert_eq!((d.width, d.height), (48, 32));
let _ = std::fs::remove_dir_all(&dir);
}
/// A stand-in for the desktop app's control server: JSON lines over TCP, answered by a headless
/// backend (the real app answers the same methods from its UI thread).
fn fake_app() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
std::thread::spawn(move || {
let mut backend = Headless::demo();
for stream in listener.incoming().flatten() {
let mut out = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { break };
let msg: Value = serde_json::from_str(&line).unwrap();
let reply = match backend.call(msg["method"].as_str().unwrap(), msg["params"].clone()) {
Ok(v) => json!({"id": msg["id"], "ok": true, "result": v}),
Err(e) => json!({"id": msg["id"], "ok": false, "error": e}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
}
}
});
addr
}
#[test]
fn remote_set_exposure_and_render() {
let addr = fake_app();
let remote = Remote::connect(&addr).unwrap();
assert!(remote.has_ui());
let mut server = Server::new(Box::new(remote));
exposure_roundtrip(&mut server);
}
#[test]
fn remote_unreachable_is_a_tool_error() {
// Bind and drop to get a port with (almost certainly) nothing listening.
let addr = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string();
let mut server = Server::new(Box::new(Remote::lazy(&addr)));
let r = session(&mut server, &[call(1, "query_photos", json!({}))]);
assert_eq!(r[0]["result"]["isError"], true);
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}