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.
320 lines
11 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|