openpencil/crates/op-editor-core/src/command_tests.rs
2026-06-02 09:42:01 +08:00

724 lines
22 KiB
Rust

//! `EditorState::apply(EditorCommand)` tests — ported from
//! shell-core's `mcp/replace_node_tests.rs` / `copy_node_tests.rs` /
//! `write_tools_tests.rs` apply branches, retargeted onto the
//! canonical `EditorState` + `EditorCommand`.
//!
//! Every test exercises the pre-validate-then-mutate discipline: a
//! rejected command leaves the document byte-for-byte unchanged.
#![cfg(test)]
use crate::command::{BatchInsertItem, EditorCommand, NodeFlag, VariableScalarPayload};
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::test_support::{rect, sample, state_with};
use crate::walkers::find_node;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::variable::{VariableDefinition, VariableKind, VariableScalar, VariableValue};
fn id(s: &str) -> NodeId {
NodeId::new(s)
}
// --- MoveNode --------------------------------------------------------
#[test]
fn move_node_reparents_into_container() {
let mut s = sample();
// Move Title (n11) under the Button group (n12).
assert!(s.apply(EditorCommand::MoveNode {
node_id: id("n11"),
target_parent: id("n12"),
page_id: None,
index: None,
}));
let group = find_node(s.active_children(), &id("n12")).unwrap();
assert!(group
.children()
.unwrap()
.iter()
.any(|c| c.id_str() == "n11"));
}
#[test]
fn move_node_rejects_cycle() {
let mut s = sample();
// Frame n10 contains n12; moving n10 under n12 would cycle.
assert!(!s.apply(EditorCommand::MoveNode {
node_id: id("n10"),
target_parent: id("n12"),
page_id: None,
index: None,
}));
// n10 still at root.
assert!(s.active_children().iter().any(|c| c.id_str() == "n10"));
}
#[test]
fn move_node_to_page_root_with_none_target() {
let mut s = sample();
assert!(s.apply(EditorCommand::MoveNode {
node_id: id("n11"),
target_parent: NodeId::NONE,
page_id: None,
index: None,
}));
// n11 now at the page root, alongside n10.
assert!(s.active_children().iter().any(|c| c.id_str() == "n11"));
}
// --- CopyNode --------------------------------------------------------
#[test]
fn copy_node_clones_subtree_with_fresh_ids() {
let mut s = sample();
let pre_max = s.max_node_id();
assert!(s.apply(EditorCommand::CopyNode {
node_id: id("n12"),
target_parent: NodeId::NONE,
page_id: None,
}));
// A clone landed at the page root; its id is fresh.
assert!(s.max_node_id() > pre_max);
// No duplicate ids in the document.
assert!(s.find_duplicate_id().is_none());
}
#[test]
fn copy_node_rejects_unknown_source() {
let mut s = sample();
assert!(!s.apply(EditorCommand::CopyNode {
node_id: id("ghost"),
target_parent: NodeId::NONE,
page_id: None,
}));
}
// --- ReplaceNode -----------------------------------------------------
#[test]
fn replace_node_swaps_at_same_slot() {
let mut s = sample();
let pre_max = s.max_node_id();
assert!(s.apply(EditorCommand::ReplaceNode {
node_id: id("n11"),
kind: "rect".into(),
name: "Swapped".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: Some("#ff0000".into()),
drop_children: false,
}));
let frame = find_node(s.active_children(), &id("n10")).unwrap();
let kids = frame.children().unwrap();
assert!(kids.iter().all(|n| n.id_str() != "n11"));
assert_eq!(kids[0].base().name.as_deref(), Some("Swapped"));
assert!(s.max_node_id() > pre_max);
// Sibling group still at slot 1.
assert_eq!(kids[1].id_str(), "n12");
}
#[test]
fn replace_node_refuses_to_drop_container_children() {
let mut s = sample();
let pre_max = s.max_node_id();
// n10 (Frame) has children — refuse without consent.
assert!(!s.apply(EditorCommand::ReplaceNode {
node_id: id("n10"),
kind: "rect".into(),
name: "WouldNuke".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: None,
drop_children: false,
}));
let frame = find_node(s.active_children(), &id("n10")).unwrap();
assert_eq!(frame.children().unwrap().len(), 2);
assert_eq!(s.max_node_id(), pre_max); // no id minted
}
#[test]
fn replace_node_drops_container_children_when_opted_in() {
let mut s = sample();
assert!(s.apply(EditorCommand::ReplaceNode {
node_id: id("n10"),
kind: "rect".into(),
name: "ExplicitNuke".into(),
x: 0,
y: 0,
width: 50,
height: 50,
fill_hex: None,
drop_children: true,
}));
let root = s.active_children();
assert!(root.iter().all(|n| n.id_str() != "n10"));
assert_eq!(root.len(), 1);
assert_eq!(root[0].base().name.as_deref(), Some("ExplicitNuke"));
}
#[test]
fn replace_node_atomic_on_bad_fill_hex() {
let mut s = sample();
let pre_max = s.max_node_id();
assert!(!s.apply(EditorCommand::ReplaceNode {
node_id: id("n11"),
kind: "rect".into(),
name: "WouldFail".into(),
x: 0,
y: 0,
width: 10,
height: 10,
fill_hex: Some("not-hex".into()),
drop_children: false,
}));
// n11 still present, no id minted.
let frame = find_node(s.active_children(), &id("n10")).unwrap();
assert!(frame
.children()
.unwrap()
.iter()
.any(|c| c.id_str() == "n11"));
assert_eq!(s.max_node_id(), pre_max);
}
// --- BatchInsert -----------------------------------------------------
#[test]
fn batch_insert_appends_all_items() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::BatchInsert {
items: vec![
BatchInsertItem {
kind: "rect".into(),
name: "a".into(),
x: 0,
y: 0,
width: 10,
height: 10,
fill_hex: None,
},
BatchInsertItem {
kind: "ellipse".into(),
name: "b".into(),
x: 20,
y: 0,
width: 10,
height: 10,
fill_hex: Some("#00ff00".into()),
},
],
}));
assert_eq!(s.active_children().len(), 2);
assert!(s.find_duplicate_id().is_none());
}
#[test]
fn batch_insert_rejects_whole_batch_on_one_bad_item() {
let mut s = state_with(vec![]);
assert!(!s.apply(EditorCommand::BatchInsert {
items: vec![
BatchInsertItem {
kind: "rect".into(),
name: "ok".into(),
x: 0,
y: 0,
width: 10,
height: 10,
fill_hex: None,
},
BatchInsertItem {
kind: "bogus".into(), // bad kind kills the batch
name: "bad".into(),
x: 0,
y: 0,
width: 10,
height: 10,
fill_hex: None,
},
],
}));
// Nothing inserted — atomic.
assert!(s.active_children().is_empty());
}
#[test]
fn batch_insert_rejects_empty() {
let mut s = state_with(vec![]);
assert!(!s.apply(EditorCommand::BatchInsert { items: vec![] }));
}
// --- Per-node attribute writers --------------------------------------
#[test]
fn set_node_fill_and_name() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::SetNodeFillHex {
node_id: id("n1"),
hex: "#123456".into(),
}));
assert!(s.apply(EditorCommand::SetNodeName {
node_id: id("n1"),
name: "Fresh".into(),
}));
let n = find_node(s.active_children(), &id("n1")).unwrap();
assert_eq!(n.base().name.as_deref(), Some("Fresh"));
assert_eq!(crate::fills::first_solid_fill_hex(n), Some("#123456"));
}
#[test]
fn set_node_fill_rejects_bad_hex() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(!s.apply(EditorCommand::SetNodeFillHex {
node_id: id("n1"),
hex: "zzz".into(),
}));
}
#[test]
fn set_node_rotation_writes_degrees() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::SetNodeRotation {
node_id: id("n1"),
degrees: 45.0,
}));
let n = find_node(s.active_children(), &id("n1")).unwrap();
assert_eq!(n.base().rotation, Some(45.0));
}
#[test]
fn set_node_text_rejects_non_text_kind() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(!s.apply(EditorCommand::SetNodeText {
node_id: id("n1"),
text: "no".into(),
}));
}
#[test]
fn set_node_text_on_text_node() {
let mut s = sample(); // n11 is a Text node
assert!(s.apply(EditorCommand::SetNodeText {
node_id: id("n11"),
text: "Updated".into(),
}));
match find_node(s.active_children(), &id("n11")).unwrap() {
PenNode::Text(t) => match &t.content {
jian_ops_schema::node::TextContent::Plain(p) => assert_eq!(p, "Updated"),
_ => panic!("expected plain content"),
},
_ => panic!("expected text node"),
}
}
#[test]
fn set_node_font_weight_range_checked() {
let mut s = sample();
assert!(s.apply(EditorCommand::SetNodeFontWeight {
node_id: id("n11"),
font_weight: 700,
}));
// Out of range → rejected.
assert!(!s.apply(EditorCommand::SetNodeFontWeight {
node_id: id("n11"),
font_weight: 0,
}));
}
#[test]
fn set_node_stroke_width_zero_clears_stroke() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::SetNodeStrokeHex {
node_id: id("n1"),
hex: "#000000".into(),
}));
assert!(crate::fills::first_solid_stroke_hex(
find_node(s.active_children(), &id("n1")).unwrap()
)
.is_some());
assert!(s.apply(EditorCommand::SetNodeStrokeWidth {
node_id: id("n1"),
width: 0.0,
}));
// Stroke gone.
assert!(crate::fills::first_solid_stroke_hex(
find_node(s.active_children(), &id("n1")).unwrap()
)
.is_none());
}
#[test]
fn commit_property_edit_writes_stroke_width() {
// Regression: committing the stroke-width input used to be a no-op
// in `commit_property_edit`, so the typed value reset on commit.
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::SetNodeStrokeHex {
node_id: id("n1"),
hex: "#114194".into(),
}));
s.set_single_selection(id("n1"));
assert!(s.commit_property_edit(crate::ui_draft::PropertyFocus::StrokeWidth, 5.0));
let w = crate::fills::node_stroke_width(find_node(s.active_children(), &id("n1")).unwrap());
assert_eq!(w, Some(5.0));
}
#[test]
fn set_node_corner_radius_rejects_negative() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(!s.apply(EditorCommand::SetNodeCornerRadius {
node_id: id("n1"),
radius: -1.0,
}));
assert!(s.apply(EditorCommand::SetNodeCornerRadius {
node_id: id("n1"),
radius: 8.0,
}));
}
#[test]
fn set_node_flag_hidden_and_locked() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::SetNodeFlag {
node_id: id("n1"),
flag: NodeFlag::Hidden,
value: true,
}));
assert!(s.apply(EditorCommand::SetNodeFlag {
node_id: id("n1"),
flag: NodeFlag::Locked,
value: true,
}));
let n = find_node(s.active_children(), &id("n1")).unwrap();
assert_eq!(n.base().visible, Some(false));
assert_eq!(n.base().locked, Some(true));
}
#[test]
fn set_node_flag_collapsed_is_unsupported() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
// No `collapsed` field on the canonical schema → rejected.
assert!(!s.apply(EditorCommand::SetNodeFlag {
node_id: id("n1"),
flag: NodeFlag::Collapsed,
value: true,
}));
}
// --- Selection -------------------------------------------------------
#[test]
fn set_selection_rejects_off_page_id() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(!s.apply(EditorCommand::SetSelection {
node_id: id("ghost")
}));
assert!(s.apply(EditorCommand::SetSelection { node_id: id("n1") }));
assert_eq!(s.selection.anchor, id("n1"));
}
#[test]
fn set_selection_set_drops_unknown_ids() {
let mut s = state_with(vec![
rect("n1", "r", 0.0, 0.0, 10.0, 10.0),
rect("n2", "r", 20.0, 0.0, 10.0, 10.0),
]);
assert!(s.apply(EditorCommand::SetSelectionSet {
node_ids: vec![id("n1"), id("ghost"), id("n2")],
}));
assert_eq!(s.selection.set, vec![id("n1"), id("n2")]);
}
#[test]
fn clear_selection_command() {
let mut s = sample();
assert!(s.apply(EditorCommand::ClearSelection));
assert!(s.selection.is_empty());
}
// --- Selection-scoped tree ops + history -----------------------------
#[test]
fn delete_selected_pushes_history() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.set_single_selection(id("n1"));
assert!(s.apply(EditorCommand::DeleteSelected));
assert!(s.active_children().is_empty());
assert!(s.history.can_undo());
}
#[test]
fn delete_selected_no_op_on_empty_selection() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.clear_selection();
assert!(!s.apply(EditorCommand::DeleteSelected));
assert!(!s.history.can_undo());
}
#[test]
fn nudge_selected_translates_and_records_history() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.set_single_selection(id("n1"));
assert!(s.apply(EditorCommand::NudgeSelected { dx: 5, dy: 7 }));
let n = find_node(s.active_children(), &id("n1")).unwrap();
assert_eq!(n.base().x, Some(5.0));
assert_eq!(n.base().y, Some(7.0));
assert!(s.history.can_undo());
}
#[test]
fn duplicate_selected_clones_with_fresh_id() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.set_single_selection(id("n1"));
assert!(s.apply(EditorCommand::DuplicateSelected { offset_px: 10 }));
assert_eq!(s.active_children().len(), 2);
assert!(s.find_duplicate_id().is_none());
}
#[test]
fn undo_redo_round_trip_via_command() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.set_single_selection(id("n1"));
assert!(s.apply(EditorCommand::DeleteSelected));
assert!(s.active_children().is_empty());
assert!(s.apply(EditorCommand::Undo));
assert_eq!(s.active_children().len(), 1);
assert!(s.apply(EditorCommand::Redo));
assert!(s.active_children().is_empty());
}
// --- Clipboard -------------------------------------------------------
#[test]
fn copy_then_paste_clipboard() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
s.set_single_selection(id("n1"));
assert!(s.apply(EditorCommand::CopySelected));
assert!(s.apply(EditorCommand::PasteClipboard { offset_px: 10 }));
assert_eq!(s.active_children().len(), 2);
}
#[test]
fn paste_clipboard_no_op_when_empty() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(!s.apply(EditorCommand::PasteClipboard { offset_px: 10 }));
}
// --- Tool + viewport -------------------------------------------------
#[test]
fn set_active_tool_command() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::SetActiveTool {
tool: "ellipse".into()
}));
assert_eq!(s.tool, crate::tool::Tool::Ellipse);
assert!(!s.apply(EditorCommand::SetActiveTool {
tool: "bogus".into()
}));
}
#[test]
fn set_viewport_partial_axes() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::SetViewport {
pan_x: Some(100),
pan_y: None,
zoom_percent: Some(200),
}));
assert_eq!(s.viewport.pan_x, 100.0);
assert_eq!(s.viewport.zoom, 2.0);
}
// --- Pages -----------------------------------------------------------
#[test]
fn add_then_set_active_page() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::AddPage { name: None }));
assert_eq!(s.page_count(), 2);
assert!(s.apply(EditorCommand::SetActivePage { index: 0 }));
assert_eq!(s.ui.active_page_index, 0);
assert!(!s.apply(EditorCommand::SetActivePage { index: 9 }));
}
#[test]
fn add_page_command_accepts_custom_name() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::AddPage {
name: Some("Checkout".into()),
}));
let pages = s.doc.pages.as_ref().expect("multi-page doc");
assert_eq!(pages[1].name, "Checkout");
}
#[test]
fn duplicate_page_command_accepts_custom_name() {
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
assert!(s.apply(EditorCommand::AddPage {
name: Some("Checkout".into()),
}));
assert!(s.apply(EditorCommand::DuplicatePage {
index: 1,
name: Some("Checkout copy 2".into()),
}));
let pages = s.doc.pages.as_ref().expect("multi-page doc");
assert_eq!(pages[2].name, "Checkout copy 2");
}
// --- Variables -------------------------------------------------------
#[test]
fn create_then_set_variable_via_command() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::CreateVariable {
name: "brand".into(),
kind: "color".into(),
default_value: "#ff0000".into(),
}));
assert!(s.apply(EditorCommand::SetVariableColor {
name: "brand".into(),
hex: "#00ff00".into(),
}));
match s.resolve_variable("brand") {
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#00ff00"),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn create_variable_rejects_bad_number_default() {
let mut s = state_with(vec![]);
assert!(!s.apply(EditorCommand::CreateVariable {
name: "n".into(),
kind: "number".into(),
default_value: "not-a-number".into(),
}));
}
#[test]
fn set_variable_scalar_number() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::CreateVariable {
name: "gap".into(),
kind: "number".into(),
default_value: "8".into(),
}));
assert!(s.apply(EditorCommand::SetVariableScalar {
name: "gap".into(),
scalar: VariableScalarPayload::Number(16.0),
}));
match s.resolve_variable("gap") {
Some(VariableScalar::Num(n)) => assert_eq!(*n, 16.0),
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn set_variables_command_merges_and_replaces_definitions() {
let mut s = state_with(vec![]);
assert!(s.apply(EditorCommand::CreateVariable {
name: "brand".into(),
kind: "color".into(),
default_value: "#ff0000".into(),
}));
let mut incoming = std::collections::BTreeMap::new();
incoming.insert(
"gap".into(),
VariableDefinition {
kind: VariableKind::Number,
value: VariableValue::Scalar(VariableScalar::Num(8.0)),
},
);
assert!(s.apply(EditorCommand::SetVariables {
variables: incoming,
replace: false,
}));
let vars = s.doc.variables.as_ref().expect("variables");
assert!(vars.contains_key("brand"));
assert!(vars.contains_key("gap"));
let mut replacement = std::collections::BTreeMap::new();
replacement.insert(
"enabled".into(),
VariableDefinition {
kind: VariableKind::Boolean,
value: VariableValue::Scalar(VariableScalar::Bool(true)),
},
);
assert!(s.apply(EditorCommand::SetVariables {
variables: replacement,
replace: true,
}));
let vars = s.doc.variables.as_ref().expect("variables");
assert!(!vars.contains_key("brand"));
assert!(!vars.contains_key("gap"));
assert!(vars.contains_key("enabled"));
}
#[test]
fn set_themes_command_merges_and_replaces_axes() {
let mut s = state_with(vec![]);
let mut themes = std::collections::BTreeMap::new();
themes.insert("Mode".into(), vec!["Light".into(), "Dark".into()]);
assert!(s.apply(EditorCommand::SetThemes {
themes,
replace: false,
}));
assert_eq!(
s.doc.themes.as_ref().and_then(|t| t.get("Mode")).cloned(),
Some(vec!["Light".into(), "Dark".into()])
);
let mut replacement = std::collections::BTreeMap::new();
replacement.insert(
"Density".into(),
vec!["Compact".into(), "Comfortable".into()],
);
assert!(s.apply(EditorCommand::SetThemes {
themes: replacement,
replace: true,
}));
let themes = s.doc.themes.as_ref().expect("themes");
assert!(!themes.contains_key("Mode"));
assert!(themes.contains_key("Density"));
}
// --- Group / ungroup / align -----------------------------------------
#[test]
fn group_then_ungroup_via_command() {
let mut s = state_with(vec![
rect("n1", "r", 0.0, 0.0, 10.0, 10.0),
rect("n2", "r", 20.0, 0.0, 10.0, 10.0),
]);
s.selection.set = vec![id("n1"), id("n2")];
s.selection.anchor = id("n2");
assert!(s.apply(EditorCommand::GroupSelected));
// The single root is now a Group.
assert_eq!(s.active_children().len(), 1);
assert!(s.active_children()[0].is_group());
assert!(s.apply(EditorCommand::UngroupSelected));
assert_eq!(s.active_children().len(), 2);
}
#[test]
fn align_selected_via_command() {
let mut s = state_with(vec![
rect("n1", "r", 10.0, 0.0, 40.0, 20.0),
rect("n2", "r", 50.0, 100.0, 30.0, 20.0),
]);
s.selection.set = vec![id("n1"), id("n2")];
s.selection.anchor = id("n2");
assert!(s.apply(EditorCommand::AlignSelected {
action: "left".into()
}));
assert!(!s.apply(EditorCommand::AlignSelected {
action: "bogus".into()
}));
}