use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; use tempfile::TempDir; struct McpProcess { child: Child, reader: BufReader, _tmp: TempDir, } impl McpProcess { fn spawn() -> Self { let tmp = TempDir::new().expect("tempdir"); let progress_path = tmp.path().join("progress.json"); let mut child = Command::new(env!("CARGO_BIN_EXE_quantum-bridge-mcp")) .env("QB_PROGRESS_PATH", &progress_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .expect("spawn"); let stdout = child.stdout.take().expect("take stdout"); let reader = BufReader::new(stdout); Self { child, reader, _tmp: tmp } } fn send(&mut self, msg: &str) { let stdin = self.child.stdin.as_mut().unwrap(); writeln!(stdin, "{msg}").unwrap(); stdin.flush().unwrap(); } fn recv(&mut self) -> serde_json::Value { let mut line = String::new(); self.reader.read_line(&mut line).unwrap(); serde_json::from_str(line.trim()).expect("response is JSON") } fn initialize(&mut self) { self.send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}"#); let _ = self.recv(); } fn extract(resp: &serde_json::Value) -> serde_json::Value { let text = resp["result"]["content"][0]["text"].as_str().unwrap(); serde_json::from_str(text).unwrap() } } impl Drop for McpProcess { fn drop(&mut self) { let _ = self.child.kill(); } } #[test] fn tools_list_exposes_seven_tools() { let mut p = McpProcess::spawn(); p.initialize(); p.send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#); let resp = p.recv(); let tools = resp["result"]["tools"].as_array().unwrap(); assert_eq!(tools.len(), 7); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); for required in ["get_lesson", "check_exercise", "explain_result", "get_progress"] { assert!(names.contains(&required), "missing {required}"); } } #[test] fn check_exercise_persists_progress_in_sandbox() { let mut p = McpProcess::spawn(); p.initialize(); let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nbit[1] c;\nx q[0];\nc = measure q;"; let call = serde_json::json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "check_exercise", "arguments": {"exercise_id": "1-1-a", "circuit": circuit}} }); p.send(&call.to_string()); let payload = McpProcess::extract(&p.recv()); assert_eq!(payload["passed"], true); assert_eq!(payload["progress_updated"], true); p.send(r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_progress","arguments":{}}}"#); let progress = McpProcess::extract(&p.recv()); assert!(progress["total_exercises_solved"].as_u64().unwrap() >= 1); } #[test] fn check_exercise_invalid_circuit_returns_diagnostics_in_payload_not_protocol_error() { let mut p = McpProcess::spawn(); p.initialize(); p.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"check_exercise","arguments":{"exercise_id":"1-1-a","circuit":"not valid qasm"}}}"#); let resp = p.recv(); assert!(resp.get("error").is_none(), "expected payload not error: {resp}"); let payload = McpProcess::extract(&resp); assert_eq!(payload["passed"], false); assert!(payload["diagnostics"].as_array().is_some()); } #[test] fn explain_result_returns_ast_based_breakdown_with_descriptions() { let mut p = McpProcess::spawn(); p.initialize(); let call = serde_json::json!({ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "explain_result", "arguments": { "circuit": "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\nh q[0];\ncx q[0], q[1];\nc = measure q;", "counts": {"00": 512, "11": 512} }} }); p.send(&call.to_string()); let payload = McpProcess::extract(&p.recv()); assert_eq!(payload["key_concept"], "entanglement"); assert_eq!(payload["num_qubits"], 2); let breakdown = payload["gate_breakdown"].as_array().unwrap(); assert_eq!(breakdown.len(), 2); assert_eq!(breakdown[0]["name"], "h"); assert_eq!(breakdown[1]["name"], "cx"); } #[test] fn golden_pass_cases_all_pass() { let golden = std::fs::read_to_string("tests/reference/tutor/exercises_pass.jsonl").unwrap(); let mut p = McpProcess::spawn(); p.initialize(); for line in golden.lines().filter(|l| !l.trim().is_empty()) { let case: serde_json::Value = serde_json::from_str(line).unwrap(); let id = case["exercise_id"].as_str().unwrap(); let circuit = case["circuit"].as_str().unwrap(); let call = serde_json::json!({ "jsonrpc": "2.0", "id": 99, "method": "tools/call", "params": {"name": "check_exercise", "arguments": {"exercise_id": id, "circuit": circuit}} }); p.send(&call.to_string()); let payload = McpProcess::extract(&p.recv()); assert!(payload["passed"].as_bool().unwrap_or(false), "id={id} payload={payload}"); } } #[test] fn golden_fail_cases_all_fail() { let golden = std::fs::read_to_string("tests/reference/tutor/exercises_fail.jsonl").unwrap(); let mut p = McpProcess::spawn(); p.initialize(); for line in golden.lines().filter(|l| !l.trim().is_empty()) { let case: serde_json::Value = serde_json::from_str(line).unwrap(); let id = case["exercise_id"].as_str().unwrap(); let circuit = case["circuit"].as_str().unwrap(); let call = serde_json::json!({ "jsonrpc": "2.0", "id": 98, "method": "tools/call", "params": {"name": "check_exercise", "arguments": {"exercise_id": id, "circuit": circuit}} }); p.send(&call.to_string()); let payload = McpProcess::extract(&p.recv()); assert_eq!(payload["passed"].as_bool().unwrap_or(true), false, "id={id} should fail"); } }