A client could only learn the top-level methods from docs/control-protocol.md;
help, methods, ping and the like all came back as "unknown method". The app
and the headless backend now answer control.methods with each method they
serve ({method, params, description}) and ping with "pong", and an unknown
method's error points to control.methods. A test keeps the app's list equal
to the doc table and checks every listed method is dispatched.
This commit is contained in:
parent
3f709bed2e
commit
7e38081b54
|
|
@ -131,7 +131,18 @@ fn launch_app(addr: &str) -> std::io::Result<()> {
|
|||
|
||||
/// Methods that only read state, so resending them after a lost reply cannot change the drawing twice.
|
||||
fn is_read_only(method: &str) -> bool {
|
||||
matches!(method, "drawing.inspect" | "document.inspect" | "engine.commands" | "cmdline.state" | "ui.inspect" | "ui.render" | "ui.screenshot")
|
||||
matches!(
|
||||
method,
|
||||
"drawing.inspect"
|
||||
| "document.inspect"
|
||||
| "engine.commands"
|
||||
| "cmdline.state"
|
||||
| "control.methods"
|
||||
| "ping"
|
||||
| "ui.inspect"
|
||||
| "ui.render"
|
||||
| "ui.screenshot"
|
||||
)
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
|
|
@ -172,6 +183,23 @@ impl Backend for Remote {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the headless `control.methods` replies: the methods [`Headless`] answers, a subset of the
|
||||
/// app's (`docs/control-protocol.md`).
|
||||
const HEADLESS_METHODS: &[(&str, &str, &str)] = &[
|
||||
("cmdline.input", "{text}", "Type a line at the command line exactly like a user (commands, prompts, keywords; empty = Enter)."),
|
||||
("cmdline.script", "{text}", "Run a multi-line script, like a .scr file."),
|
||||
("cmdline.key", "{key: \"enter\"|\"escape\"}", "Press Enter or Escape at the command line."),
|
||||
("cmdline.state", "", "Current prompt, keywords, accepted input kinds and history tail."),
|
||||
("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."),
|
||||
("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."),
|
||||
("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."),
|
||||
("ui.render", "{path?, width?, height?, fit?}", "Render the drawing; replies pngBase64."),
|
||||
("app.open", "{path}", "Open a drawing."),
|
||||
("app.save", "{path?}", "Save the drawing (Save As with a path)."),
|
||||
("control.methods", "", "This list."),
|
||||
("ping", "", "Replies \"pong\"."),
|
||||
];
|
||||
|
||||
/// An in-process headless session (no window).
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
|
|
@ -288,7 +316,12 @@ impl Backend for Headless {
|
|||
s.execute(id, &p).map_err(|e| e.to_string())
|
||||
}
|
||||
"ui.screenshot" => Err("no UI in headless mode; use render".into()),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
"control.methods" => Ok(HEADLESS_METHODS
|
||||
.iter()
|
||||
.map(|(method, params, description)| json!({"method": method, "params": params, "description": description}))
|
||||
.collect()),
|
||||
"ping" => Ok(json!("pong")),
|
||||
other => Err(format!("unknown method `{other}`; `control.methods` lists them (docs/control-protocol.md)")),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
//! The headless backend's `control.methods` lists the methods it answers, all of them documented
|
||||
//! in `docs/control-protocol.md` (the app's list is tested in `crates/ui-egui/tests/control_methods.rs`).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use cadcraft_mcp::{Backend, Headless};
|
||||
use serde_json::json;
|
||||
|
||||
fn documented() -> BTreeSet<String> {
|
||||
let doc = include_str!("../../../docs/control-protocol.md");
|
||||
let table = doc.lines().skip_while(|l| !l.starts_with("| Method |")).skip(2).take_while(|l| l.starts_with('|'));
|
||||
table.filter_map(|l| l.split('|').nth(1)).flat_map(|cell| cell.split('`').skip(1).step_by(2).map(str::to_string).collect::<Vec<_>>()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_methods_lists_what_headless_answers() {
|
||||
let mut b = Headless::default();
|
||||
let methods = b.call("control.methods", json!({})).unwrap();
|
||||
let methods = methods.as_array().unwrap();
|
||||
let doc = documented();
|
||||
let missing = std::env::temp_dir().join("cadcraft-843-no-such-dir").join("x.dxf");
|
||||
let params = json!({"path": missing.to_string_lossy()});
|
||||
for m in methods {
|
||||
let name = m["method"].as_str().unwrap();
|
||||
assert!(doc.contains(name), "{name} is not in docs/control-protocol.md");
|
||||
assert!(m["params"].is_string() && m["description"].as_str().is_some_and(|d| !d.is_empty()), "{m}");
|
||||
if let Err(e) = b.call(name, params.clone()) {
|
||||
assert!(!e.starts_with("unknown method"), "{name}: {e}");
|
||||
}
|
||||
}
|
||||
let names: BTreeSet<&str> = methods.iter().filter_map(|m| m["method"].as_str()).collect();
|
||||
assert!(names.contains("control.methods") && names.contains("ping") && names.contains("engine.execute"), "{names:?}");
|
||||
assert!(!names.contains("ui.inspect"), "headless has no UI to inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_answers_and_unknown_method_points_to_control_methods() {
|
||||
let mut b = Headless::default();
|
||||
assert_eq!(b.call("ping", json!({})).unwrap(), json!("pong"));
|
||||
let e = b.call("help", json!({})).unwrap_err();
|
||||
assert!(e.starts_with("unknown method `help`") && e.contains("control.methods"), "{e}");
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless
|
||||
//! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`)
|
||||
//! - `app.open {path}`, `app.save {path?}`, `app.quit`
|
||||
//! - `control.methods`: this list, with params and a description; `ping`: replies `"pong"`
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
|
@ -185,6 +186,42 @@ fn wrap(r: Result<Value, String>) -> Outcome {
|
|||
}
|
||||
}
|
||||
|
||||
/// What `control.methods` replies: method, params, description. Keep in step with `handle` and the
|
||||
/// table in `docs/control-protocol.md` (`tests/control_methods.rs` checks the table).
|
||||
const METHODS: &[(&str, &str, &str)] = &[
|
||||
("cmdline.input", "{text}", "Type a line at the command line exactly like a user (commands, prompts, keywords; empty = Enter)."),
|
||||
("cmdline.script", "{text}", "Run a multi-line script, like a .scr file."),
|
||||
("cmdline.key", "{key: \"enter\"|\"escape\"}", "Press Enter or Escape at the command line."),
|
||||
("cmdline.state", "", "Current prompt, keywords, accepted input kinds and history tail."),
|
||||
("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."),
|
||||
("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."),
|
||||
("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."),
|
||||
("ui.inspect", "", "UI state, canvas rect, view, cursor and snap, performance, keyboard focus."),
|
||||
("ui.elements", "{label?, role?, focused?}", "Observed visible controls with their egui id, rectangle, state and focus."),
|
||||
("ui.menu.list", "", "The menu tree."),
|
||||
("ui.menu.invoke", "{command}", "Invoke a menu item like a click (interactive)."),
|
||||
("ui.pointer", "{x, y, space?, button?, action?}", "Click or move in the drawing area (world coordinates by default). Right button = Enter."),
|
||||
("ui.click", "{x, y, button?, shift?, ctrl?}", "Real egui click in screen points."),
|
||||
("ui.move", "{x, y}", "Real egui pointer move in screen points."),
|
||||
("ui.drag", "{x, y, to: [x, y], button?}", "Real press, move and release."),
|
||||
("ui.key", "{key, cmd?, shift?, alt?}", "Synthetic key press."),
|
||||
("ui.text", "{text}", "Synthetic text input."),
|
||||
("ui.set", "UiState fields", "Change UI state: palettes, toolbars, command line, theme…"),
|
||||
("ui.resize", "{width, height}", "Resize the window."),
|
||||
("ui.focus", "", "Activate the native window."),
|
||||
("ui.screenshot", "{path?}", "PNG of the window (needs a presented frame)."),
|
||||
("ui.render", "{path?, width?, height?, fit?}", "Headless render of the drawing; replies pngBase64."),
|
||||
("app.open", "{path}", "Open a drawing."),
|
||||
("app.save", "{path?}", "Save the drawing (Save As with a path)."),
|
||||
("app.quit", "", "Quit without the unsaved-changes prompt."),
|
||||
("control.methods", "", "This list."),
|
||||
("ping", "", "Replies \"pong\"."),
|
||||
];
|
||||
|
||||
fn methods_json() -> Value {
|
||||
METHODS.iter().map(|(method, params, description)| json!({"method": method, "params": params, "description": description})).collect()
|
||||
}
|
||||
|
||||
pub fn all_commands(app: &CadApp) -> Value {
|
||||
let mut v: Vec<Value> = cadcraft_engine::command_specs().iter().map(|c| serde_json::to_value(c.info(&app.session)).unwrap_or_default()).collect();
|
||||
for (id, label, menu, sc) in crate::menus::UI_COMMANDS {
|
||||
|
|
@ -461,7 +498,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
|||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
}
|
||||
other => err(format!("unknown method `{other}`")),
|
||||
"control.methods" => ok(methods_json()),
|
||||
"ping" => ok(json!("pong")),
|
||||
other => err(format!("unknown method `{other}`; `control.methods` lists them (docs/control-protocol.md)")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
//! `control.methods` lists what the control channel answers, matching the table in
|
||||
//! `docs/control-protocol.md`; `ping` answers; an unknown method points to `control.methods` (#843).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle};
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn call(app: &mut CadApp, method: &str, params: Value) -> Value {
|
||||
let (req, _reply) = ControlRequest::new(method, params);
|
||||
match handle(app, &egui::Context::default(), &req) {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> CadApp {
|
||||
CadApp::new(Session::new(), Services::default())
|
||||
}
|
||||
|
||||
/// The method names in the first column of the doc's method table.
|
||||
fn documented() -> BTreeSet<String> {
|
||||
let doc = include_str!("../../../docs/control-protocol.md");
|
||||
let table = doc.lines().skip_while(|l| !l.starts_with("| Method |")).skip(2).take_while(|l| l.starts_with('|'));
|
||||
table.filter_map(|l| l.split('|').nth(1)).flat_map(|cell| cell.split('`').skip(1).step_by(2).map(str::to_string).collect::<Vec<_>>()).collect()
|
||||
}
|
||||
|
||||
fn listed(app: &mut CadApp) -> Vec<Value> {
|
||||
let v = call(app, "control.methods", json!({}));
|
||||
assert_eq!(v["ok"], true, "{v}");
|
||||
v["result"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_methods_matches_the_documented_table() {
|
||||
let mut app = app();
|
||||
let methods = listed(&mut app);
|
||||
for m in &methods {
|
||||
assert!(m["method"].is_string() && m["params"].is_string() && m["description"].as_str().is_some_and(|d| !d.is_empty()), "{m}");
|
||||
}
|
||||
let names: BTreeSet<String> = methods.iter().filter_map(|m| m["method"].as_str().map(str::to_string)).collect();
|
||||
assert_eq!(names.len(), methods.len(), "a method is listed twice");
|
||||
let doc = documented();
|
||||
assert!(doc.contains("control.methods") && doc.contains("ping"), "{doc:?}");
|
||||
assert_eq!(names, doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_method_is_answered() {
|
||||
let mut app = app();
|
||||
let missing = std::env::temp_dir().join("cadcraft-843-no-such-dir").join("x.dxf");
|
||||
let params = json!({"path": missing.to_string_lossy()});
|
||||
for m in listed(&mut app) {
|
||||
let name = m["method"].as_str().unwrap_or_default();
|
||||
let r = call(&mut app, name, params.clone());
|
||||
assert!(!r["error"].as_str().is_some_and(|e| e.starts_with("unknown method")), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_answers() {
|
||||
assert_eq!(call(&mut app(), "ping", json!({})), json!({"ok": true, "result": "pong"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_method_points_to_control_methods() {
|
||||
let r = call(&mut app(), "help", json!({}));
|
||||
assert_eq!(r["ok"], false);
|
||||
let e = r["error"].as_str().unwrap_or_default();
|
||||
assert!(e.starts_with("unknown method `help`") && e.contains("control.methods"), "{e}");
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I
|
|||
{"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}}
|
||||
```
|
||||
|
||||
Errors come back as `{"id": …, "ok": false, "error": "message"}`.
|
||||
Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points
|
||||
to `control.methods`, which lists the methods the server answers.
|
||||
|
||||
The server waits up to 60 s for the app to answer a request. After that it replies with a timeout, and
|
||||
`state` tells whether sending the request again is safe:
|
||||
|
|
@ -42,6 +43,8 @@ The server waits up to 60 s for the app to answer a request. After that it repli
|
|||
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). |
|
||||
| `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. |
|
||||
| `app.open` / `app.save` / `app.quit` | `{path}` | File operations. |
|
||||
| `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. |
|
||||
| `ping` | | Replies `"pong"`. A cheap health check. |
|
||||
|
||||
The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless
|
||||
(`cadcraft-cli mcp`) it serves the same methods from an in-process session.
|
||||
|
|
|
|||
Loading…
Reference in New Issue