openpencil/crates/op-mcp/src/replace_node_tests.rs
Kayshen-X 0a8fdc1ffc fix(mcp): hoist node-level app state on every insert path via command batch
Every generation insert/replace path now drains node-level state to
one doc-root MergeAppState (weakest priority, doc-owned keys always
win): insert_node + standalone replace_node wrap via with_hoisted_state;
batch_program's I()/R() sibling-emit through the program finisher's own
batch — emitted only after the insert/replace line succeeds so a failed
best-effort line cannot leak orphan state (recorded order keeps merge
before its insert). Includes the radio_group promote e2e and 8 new
hoist/leak regression tests. The shared helpers landed with the
script-gen series' batch_design commit.
2026-07-03 22:13:03 +08:00

320 lines
11 KiB
Rust

//! Tests for `mcp::write_tools::ReplaceNode`.
//!
//! Ported off the old shell-core `Document` onto `op_editor_core::
//! EditorState`. Tool-layer validation + `EditorCommand` emission,
//! plus end-to-end `EditorState::apply` checks for the destructive-
//! swap guard. The apply-path correctness is covered by
//! `op-editor-core`'s `command_tests.rs`.
use super::test_fixtures::sample;
use super::write_tools::*;
use super::{EditorCommand, McpTool, ToolErrorCode, ToolOutcome};
use op_editor_core::{NodeId, PenNodeExt};
use std::collections::BTreeMap;
#[test]
fn replace_node_validates_required_args() {
let tool = replace_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(), "n11".into());
match tool.call(&args) {
ToolOutcome::Err(code, msg) => {
assert_eq!(code, ToolErrorCode::MissingArgument);
assert!(msg.contains("kind"));
}
_ => panic!(),
}
args.insert("kind".into(), "rect".into());
args.insert("name".into(), "Replacement".into());
args.insert("x".into(), "10".into());
args.insert("y".into(), "20".into());
args.insert("width".into(), "100".into());
args.insert("height".into(), "30".into());
match tool.call(&args) {
ToolOutcome::OkWithCommand(
_,
EditorCommand::ReplaceNode {
node_id,
kind,
name,
x,
y,
width,
height,
fill_hex,
drop_children,
page_id,
},
) => {
assert_eq!(node_id.as_str(), "n11");
assert_eq!(kind, "rect");
assert_eq!(name, "Replacement");
assert_eq!(x, 10);
assert_eq!(y, 20);
assert_eq!(width, 100);
assert_eq!(height, 30);
assert!(fill_hex.is_none());
assert!(
!drop_children,
"default opt-out keeps container subtrees safe"
);
assert!(page_id.is_none());
}
other => panic!("expected ReplaceNode, got {other:?}"),
}
}
#[test]
fn replace_node_accepts_ts_node_id_data_and_page_args() {
let tool = replace_node_snapshot();
let mut args = BTreeMap::new();
args.insert("nodeId".into(), "n11".into());
args.insert(
"data".into(),
r##"{"type":"rectangle","name":"Replacement","x":10,"y":20,"width":100,"height":30,"fill":[{"type":"solid","color":"#112233"}]}"##.into(),
);
args.insert("pageId".into(), "page-2".into());
match tool.call(&args) {
ToolOutcome::OkWithCommand(
_,
EditorCommand::ReplaceNode {
node_id,
kind,
name,
fill_hex,
page_id,
..
},
) => {
assert_eq!(node_id.as_str(), "n11");
assert_eq!(kind, "rect");
assert_eq!(name, "Replacement");
assert_eq!(fill_hex.as_deref(), Some("#112233"));
assert_eq!(page_id.as_deref(), Some("page-2"));
}
other => panic!("expected ReplaceNode command with TS args, got {other:?}"),
}
}
#[test]
fn replace_node_accepts_ts_data_tree_as_subtree() {
let tool = replace_node_snapshot();
let mut args = BTreeMap::new();
args.insert("nodeId".into(), "n11".into());
args.insert(
"data".into(),
r##"{"type":"frame","name":"Card","width":200,"height":120,"children":[{"type":"text","name":"Title","content":"Hello","width":100,"height":24}]}"##
.into(),
);
match tool.call(&args) {
ToolOutcome::OkWithCommand(
_,
EditorCommand::ReplaceSubtree {
node_id,
node,
drop_children,
page_id,
},
) => {
assert_eq!(node_id.as_str(), "n11");
assert_eq!(node.base().name.as_deref(), Some("Card"));
assert_eq!(node.children().expect("children").len(), 1);
assert!(!drop_children);
assert!(page_id.is_none());
}
other => panic!("expected ReplaceSubtree command from TS data tree, got {other:?}"),
}
}
#[test]
fn replace_node_data_hoists_node_state() {
// A generated `data` replacement declaring node-level `state` must
// hoist it to a doc-root MergeAppState (unplanned priority) and
// strip it off the ReplaceSubtree payload — same contract as the
// insert paths and batch_program's R().
let tool = replace_node_snapshot();
let mut args = BTreeMap::new();
args.insert("nodeId".into(), "n11".into());
args.insert(
"data".into(),
r##"{"type":"frame","name":"Counter","width":200,"height":100,"state":{"n":{"type":"int","default":0}},"children":[{"type":"text","content":"hi"}]}"##
.into(),
);
match tool.call(&args) {
ToolOutcome::OkWithCommand(_, EditorCommand::Batch { commands }) => {
assert_eq!(commands.len(), 2);
assert!(
matches!(&commands[0], EditorCommand::MergeAppState { plan_idx, state }
if *plan_idx == usize::MAX && state.contains_key("n"))
);
match &commands[1] {
EditorCommand::ReplaceSubtree { node, .. } => {
let v = serde_json::to_value(node.as_ref()).expect("json");
assert!(
v.get("state").is_none(),
"replacement state must be stripped"
);
}
other => panic!("expected ReplaceSubtree second, got {other:?}"),
}
}
other => panic!("expected Batch command, got {other:?}"),
}
}
#[test]
fn replace_node_preserves_ts_leaf_data_with_extra_fields_as_subtree() {
let mut args = BTreeMap::new();
args.insert("nodeId".into(), "n11".into());
args.insert("data".into(), r##"{"type":"text","name":"Hero","content":"Welcome","width":240,"height":48,"fontSize":24}"##.into());
let ToolOutcome::OkWithCommand(_, EditorCommand::ReplaceSubtree { node, .. }) =
replace_node_snapshot().call(&args)
else {
panic!("expected ReplaceSubtree preserving TS leaf data");
};
let jian_ops_schema::node::PenNode::Text(text) = node.as_ref() else {
panic!("expected text node, got {node:?}");
};
assert_eq!(text.base.name.as_deref(), Some("Hero"));
assert_eq!(text.font_size, Some(24.0));
}
#[test]
fn replace_node_rejects_bad_kind_and_geometry_and_fill() {
let tool = replace_node_snapshot();
let mut args = BTreeMap::new();
args.insert("node_id".into(), "n11".into());
args.insert("kind".into(), "blob".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());
match tool.call(&args) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
_ => panic!(),
}
args.insert("kind".into(), "rect".into());
args.insert("width".into(), "-5".into());
match tool.call(&args) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
_ => panic!(),
}
args.insert("width".into(), "5".into());
args.insert("fill_hex".into(), "not-a-hex".into());
match tool.call(&args) {
ToolOutcome::Err(code, _) => assert_eq!(code, ToolErrorCode::InvalidArgument),
_ => panic!(),
}
}
#[test]
fn replace_node_command_swaps_leaf_at_same_slot() {
let mut s = sample();
// Title (n11) is a leaf — drop_children=false still swaps it.
assert!(s.apply(EditorCommand::ReplaceNode {
node_id: NodeId::new("n11"),
kind: "rect".into(),
name: "Swapped".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: Some("#ff0000".into()),
drop_children: false,
page_id: None,
}));
}
#[test]
fn replace_node_command_rejects_unknown_id() {
let mut s = sample();
assert!(!s.apply(EditorCommand::ReplaceNode {
node_id: NodeId::new("n99999"),
kind: "rect".into(),
name: "Ghost".into(),
x: 0,
y: 0,
width: 10,
height: 10,
fill_hex: None,
drop_children: false,
page_id: None,
}));
}
#[test]
fn replace_node_command_refuses_to_drop_container_children() {
let mut s = sample();
// Frame (n10) has children — refuse without explicit consent.
assert!(!s.apply(EditorCommand::ReplaceNode {
node_id: NodeId::new("n10"),
kind: "rect".into(),
name: "WouldNuke".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: None,
drop_children: false,
page_id: None,
}));
// With explicit consent the swap succeeds.
assert!(s.apply(EditorCommand::ReplaceNode {
node_id: NodeId::new("n10"),
kind: "rect".into(),
name: "ExplicitNuke".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: None,
drop_children: true,
page_id: None,
}));
}
#[test]
fn replace_node_rejects_malformed_drop_children() {
let tool = replace_node_snapshot();
let mut args = BTreeMap::new();
args.insert("node_id".into(), "n11".into());
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());
for bad in ["yes", "", "TRUE", "1", "0"] {
args.insert("drop_children".into(), bad.into());
match tool.call(&args) {
ToolOutcome::Err(code, msg) => {
assert_eq!(code, ToolErrorCode::InvalidArgument, "input {bad:?}");
assert!(msg.contains("drop_children"), "input {bad:?} msg = {msg}");
}
_ => panic!("expected InvalidArgument on malformed drop_children {bad:?}"),
}
}
}
#[test]
fn parser_refuses_structured_drop_children_at_wire_layer() {
use crate::parse_tool_call;
for bad_json in [
r#"{"id":1,"method":"replace_node","params":{"node_id":"n10","kind":"rect","name":"X","x":"0","y":"0","width":"5","height":"5","drop_children":{}}}"#,
r#"{"id":1,"method":"replace_node","params":{"node_id":"n10","kind":"rect","name":"X","x":"0","y":"0","width":"5","height":"5","drop_children":[true]}}"#,
r#"{"id":1,"method":"replace_node","params":{"node_id":"n10","kind":"rect","name":{},"x":"0","y":"0","width":"5","height":"5"}}"#,
] {
assert!(
parse_tool_call(bad_json).is_none(),
"structured arg must refuse parse: {bad_json}"
);
}
}