openpencil/crates/op-mcp/src/write_tools_tests.rs
Kayshen-X e2e287c008 refactor: extract op-mcp crate
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.
2026-05-16 23:21:17 +08:00

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"));
}