Relocate mcp.rs + mcp/* + mcp_tests.rs out of openpencil-shell-core into a dedicated op-mcp crate. The MCP tool registry + JSON-RPC stdio layer only depends on jian-ops-schema + op-editor-core (its tools build on EditorState / EditorCommand). mcp.rs becomes the crate's lib.rs; the mcp/ submodule files flatten to src/ so the `pub mod` declarations resolve unchanged, and intra-module `super::` paths stay valid. mcp_tests.rs becomes a #[cfg(test)] mod of lib.rs with its `super::mcp::` paths rewritten to `crate::`. openpencil-desktop's mcp_serve.rs now imports `op_mcp::*`. Pure relocation, no behaviour change; 138 tests move with their code.
505 lines
16 KiB
Rust
505 lines
16 KiB
Rust
//! Tests for `mcp::write_tools`.
|
|
//!
|
|
//! Ported off the old shell-core `Document` / `McpCommand` onto
|
|
//! `op_editor_core::EditorState` / `EditorCommand`. These cover the
|
|
//! tool layer — argument validation + `EditorCommand` emission, plus a
|
|
//! handful of end-to-end checks through `EditorState::apply`. The
|
|
//! apply-path correctness itself is exhaustively covered by
|
|
//! `op-editor-core`'s own `command_tests.rs`.
|
|
|
|
use super::test_fixtures::{add_theme_axis, add_variable, sample, state_with};
|
|
use super::write_tools::*;
|
|
use super::{EditorCommand, McpTool, ToolErrorCode, ToolOutcome};
|
|
use jian_ops_schema::variable::{VariableKind, VariableScalar};
|
|
use op_editor_core::EditorState;
|
|
use std::collections::BTreeMap;
|
|
|
|
fn state_with_color_var(name: &str, hex: &str) -> EditorState {
|
|
let mut s = state_with(vec![]);
|
|
add_variable(
|
|
&mut s,
|
|
name,
|
|
VariableKind::Color,
|
|
VariableScalar::Str(hex.to_string()),
|
|
);
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn set_variable_color_validates_args_and_returns_command() {
|
|
let s = state_with_color_var("brand", "#ff8800");
|
|
let tool = set_variable_color_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("name".into(), "brand".into());
|
|
args.insert("hex".into(), "#00ff00".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::OkWithCommand(out, cmd) => {
|
|
assert_eq!(out.get("wrote"), Some(&"true".to_string()));
|
|
match cmd {
|
|
EditorCommand::SetVariableColor { name, hex } => {
|
|
assert_eq!(name, "brand");
|
|
assert_eq!(hex, "#00ff00");
|
|
}
|
|
other => panic!("expected SetVariableColor, got {other:?}"),
|
|
}
|
|
}
|
|
other => panic!("expected OkWithCommand, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_variable_color_errors_on_missing_args() {
|
|
let s = state_with_color_var("brand", "#ff8800");
|
|
let tool = set_variable_color_snapshot(&s);
|
|
match tool.call(&BTreeMap::new()) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
|
|
_ => panic!("expected MissingArgument"),
|
|
}
|
|
let mut args = BTreeMap::new();
|
|
args.insert("name".into(), "brand".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::MissingArgument);
|
|
assert!(msg.contains("hex"));
|
|
}
|
|
_ => panic!("expected MissingArgument"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_variable_color_errors_on_unknown_variable() {
|
|
let s = state_with_color_var("brand", "#ff8800");
|
|
let tool = set_variable_color_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("name".into(), "no-such-var".into());
|
|
args.insert("hex".into(), "#000000".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::ToolFailed);
|
|
assert!(msg.contains("no-such-var"));
|
|
}
|
|
_ => panic!("expected ToolFailed"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_variable_color_errors_on_invalid_hex() {
|
|
let s = state_with_color_var("brand", "#ff8800");
|
|
let tool = set_variable_color_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("name".into(), "brand".into());
|
|
for bad in &["not-hex", "ff00ff", "#12", "#fffffg"] {
|
|
args.insert("hex".into(), (*bad).into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => {
|
|
assert_eq!(code, ToolErrorCode::InvalidArgument, "hex={bad}")
|
|
}
|
|
_ => panic!("expected InvalidArgument for {bad}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_variable_color_command_applies_through_editor_state() {
|
|
let mut s = state_with_color_var("brand", "#ff8800");
|
|
let cmd = EditorCommand::SetVariableColor {
|
|
name: "brand".into(),
|
|
hex: "#11ccaa".into(),
|
|
};
|
|
assert!(s.apply(cmd));
|
|
match s.resolve_variable("brand") {
|
|
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#11ccaa"),
|
|
other => panic!("unexpected {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn state_with_theme_axis(name: &str, values: &[&str]) -> EditorState {
|
|
let mut s = state_with(vec![]);
|
|
add_theme_axis(&mut s, name, values);
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn set_active_axis_value_validates_args_and_returns_command() {
|
|
let s = state_with_theme_axis("mode", &["light", "dark"]);
|
|
let tool = set_active_axis_value_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("axis".into(), "mode".into());
|
|
args.insert("value".into(), "dark".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::OkWithCommand(out, cmd) => {
|
|
assert_eq!(out.get("wrote"), Some(&"true".to_string()));
|
|
match cmd {
|
|
EditorCommand::SetActiveAxisValue { axis, value } => {
|
|
assert_eq!(axis, "mode");
|
|
assert_eq!(value, "dark");
|
|
}
|
|
other => panic!("expected SetActiveAxisValue, got {other:?}"),
|
|
}
|
|
}
|
|
other => panic!("expected OkWithCommand, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_active_axis_value_errors_on_missing_args() {
|
|
let s = state_with_theme_axis("mode", &["light", "dark"]);
|
|
let tool = set_active_axis_value_snapshot(&s);
|
|
match tool.call(&BTreeMap::new()) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
|
|
_ => panic!(),
|
|
}
|
|
let mut args = BTreeMap::new();
|
|
args.insert("axis".into(), "mode".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::MissingArgument);
|
|
assert!(msg.contains("value"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_active_axis_value_errors_on_unknown_axis() {
|
|
let s = state_with_theme_axis("mode", &["light", "dark"]);
|
|
let tool = set_active_axis_value_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("axis".into(), "density".into());
|
|
args.insert("value".into(), "compact".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::ToolFailed);
|
|
assert!(msg.contains("density"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_active_axis_value_errors_on_value_not_in_axis() {
|
|
let s = state_with_theme_axis("mode", &["light", "dark"]);
|
|
let tool = set_active_axis_value_snapshot(&s);
|
|
let mut args = BTreeMap::new();
|
|
args.insert("axis".into(), "mode".into());
|
|
args.insert("value".into(), "sepia".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::InvalidArgument);
|
|
assert!(msg.contains("light"));
|
|
assert!(msg.contains("dark"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_active_axis_value_command_applies_through_editor_state() {
|
|
let mut s = state_with_theme_axis("mode", &["light", "dark"]);
|
|
assert!(s.apply(EditorCommand::SetActiveAxisValue {
|
|
axis: "mode".into(),
|
|
value: "dark".into(),
|
|
}));
|
|
assert_eq!(
|
|
s.ui.variables.active_theme.get("mode").map(String::as_str),
|
|
Some("dark")
|
|
);
|
|
assert!(!s.apply(EditorCommand::SetActiveAxisValue {
|
|
axis: "mode".into(),
|
|
value: "sepia".into(),
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn insert_node_validates_required_args() {
|
|
let tool = insert_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("name".into(), "X".into());
|
|
args.insert("x".into(), "0".into());
|
|
args.insert("y".into(), "0".into());
|
|
args.insert("width".into(), "10".into());
|
|
args.insert("height".into(), "10".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::MissingArgument);
|
|
assert!(msg.contains("kind"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
args.insert("kind".into(), "frobnicate".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn insert_node_validates_numeric_args() {
|
|
let tool = insert_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("kind".into(), "rect".into());
|
|
args.insert("name".into(), "X".into());
|
|
args.insert("x".into(), "not-a-number".into());
|
|
args.insert("y".into(), "0".into());
|
|
args.insert("width".into(), "10".into());
|
|
args.insert("height".into(), "10".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
args.insert("x".into(), "0".into());
|
|
args.insert("width".into(), "-5".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::InvalidArgument);
|
|
assert!(msg.contains("non-negative"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn insert_node_validates_optional_fill_hex() {
|
|
let tool = insert_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("kind".into(), "rect".into());
|
|
args.insert("name".into(), "X".into());
|
|
args.insert("x".into(), "0".into());
|
|
args.insert("y".into(), "0".into());
|
|
args.insert("width".into(), "10".into());
|
|
args.insert("height".into(), "10".into());
|
|
args.insert("fill_hex".into(), "not-hex".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn insert_node_returns_command_with_parsed_args() {
|
|
let tool = insert_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("kind".into(), "rect".into());
|
|
args.insert("name".into(), "My Rect".into());
|
|
args.insert("x".into(), "10".into());
|
|
args.insert("y".into(), "20".into());
|
|
args.insert("width".into(), "100".into());
|
|
args.insert("height".into(), "50".into());
|
|
args.insert("fill_hex".into(), "#ff0000".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::OkWithCommand(
|
|
_,
|
|
EditorCommand::InsertNode {
|
|
kind,
|
|
name,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
fill_hex,
|
|
},
|
|
) => {
|
|
assert_eq!(kind, "rect");
|
|
assert_eq!(name, "My Rect");
|
|
assert_eq!(x, 10);
|
|
assert_eq!(y, 20);
|
|
assert_eq!(width, 100);
|
|
assert_eq!(height, 50);
|
|
assert_eq!(fill_hex.as_deref(), Some("#ff0000"));
|
|
}
|
|
other => panic!("expected InsertNode command, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn insert_node_command_applies_through_editor_state() {
|
|
let mut s = state_with(vec![]);
|
|
let before = s.active_children().len();
|
|
assert!(s.apply(EditorCommand::InsertNode {
|
|
kind: "rect".into(),
|
|
name: "Created".into(),
|
|
x: 50,
|
|
y: 60,
|
|
width: 200,
|
|
height: 150,
|
|
fill_hex: Some("#00ff00".into()),
|
|
}));
|
|
assert_eq!(s.active_children().len(), before + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn update_node_requires_node_id_and_one_field() {
|
|
let tool = update_node_snapshot();
|
|
match tool.call(&BTreeMap::new()) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
|
|
_ => panic!(),
|
|
}
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "n10".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::MissingArgument);
|
|
assert!(msg.contains("at least one"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn update_node_rejects_empty_node_id() {
|
|
// The canonical schema uses string ids — any non-empty string is
|
|
// syntactically valid; only the empty string (NONE sentinel) is
|
|
// rejected at the wire layer.
|
|
let tool = update_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "".into());
|
|
args.insert("name".into(), "ok".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn update_node_returns_command_with_partial_patch() {
|
|
let tool = update_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "n10".into());
|
|
args.insert("x".into(), "50".into());
|
|
args.insert("width".into(), "200".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::OkWithCommand(
|
|
_,
|
|
EditorCommand::UpdateNode {
|
|
node_id,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
name,
|
|
fill_hex,
|
|
},
|
|
) => {
|
|
assert_eq!(node_id.as_str(), "n10");
|
|
assert_eq!(x, Some(50));
|
|
assert_eq!(y, None);
|
|
assert_eq!(width, Some(200));
|
|
assert_eq!(height, None);
|
|
assert!(name.is_none());
|
|
assert!(fill_hex.is_none());
|
|
}
|
|
other => panic!("expected UpdateNode command, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn update_node_command_applies_through_editor_state() {
|
|
let mut s = sample();
|
|
assert!(s.apply(EditorCommand::UpdateNode {
|
|
node_id: op_editor_core::NodeId::new("n11"),
|
|
x: Some(100),
|
|
y: None,
|
|
width: None,
|
|
height: Some(50),
|
|
name: Some("Renamed".into()),
|
|
fill_hex: None,
|
|
}));
|
|
// Unknown id rejects.
|
|
assert!(!s.apply(EditorCommand::UpdateNode {
|
|
node_id: op_editor_core::NodeId::new("n99999"),
|
|
x: Some(0),
|
|
y: None,
|
|
width: None,
|
|
height: None,
|
|
name: None,
|
|
fill_hex: None,
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_node_validates_arg() {
|
|
let tool = delete_node_snapshot();
|
|
match tool.call(&BTreeMap::new()) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
|
|
_ => panic!(),
|
|
}
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn delete_node_command_applies_through_editor_state() {
|
|
let mut s = sample();
|
|
assert!(s.apply(EditorCommand::DeleteNode {
|
|
node_id: op_editor_core::NodeId::new("n11"),
|
|
}));
|
|
assert!(!s.apply(EditorCommand::DeleteNode {
|
|
node_id: op_editor_core::NodeId::new("n99999"),
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn move_node_validates_args() {
|
|
let tool = move_node_snapshot();
|
|
match tool.call(&BTreeMap::new()) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::MissingArgument),
|
|
_ => panic!(),
|
|
}
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "n10".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, msg) => {
|
|
assert_eq!(code, ToolErrorCode::MissingArgument);
|
|
assert!(msg.contains("target_parent_id"));
|
|
}
|
|
_ => panic!(),
|
|
}
|
|
// node_id == target_parent_id rejects.
|
|
args.insert("node_id".into(), "n10".into());
|
|
args.insert("target_parent_id".into(), "n10".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
|
|
_ => panic!(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn move_node_returns_command_with_empty_target_for_page_root() {
|
|
let tool = move_node_snapshot();
|
|
let mut args = BTreeMap::new();
|
|
args.insert("node_id".into(), "n11".into());
|
|
// Legacy "0" + the empty string both map to the page-root sentinel.
|
|
args.insert("target_parent_id".into(), "0".into());
|
|
match tool.call(&args) {
|
|
ToolOutcome::OkWithCommand(
|
|
_,
|
|
EditorCommand::MoveNode {
|
|
node_id,
|
|
target_parent,
|
|
},
|
|
) => {
|
|
assert_eq!(node_id.as_str(), "n11");
|
|
assert!(!target_parent.is_real(), "0 maps to the page-root NONE");
|
|
}
|
|
other => panic!("expected MoveNode, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn move_node_command_applies_through_editor_state() {
|
|
let mut s = sample();
|
|
// Move Title (n11) to the page root.
|
|
assert!(s.apply(EditorCommand::MoveNode {
|
|
node_id: op_editor_core::NodeId::new("n11"),
|
|
target_parent: op_editor_core::NodeId::NONE,
|
|
}));
|
|
assert!(s
|
|
.active_children()
|
|
.iter()
|
|
.any(|n| op_editor_core::pen_node_ext::PenNodeExt::id_str(n) == "n11"));
|
|
}
|