diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index e3c955bb8..feb4f7462 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -54,6 +54,11 @@ fn main_depth(app: &OpenCADStudio) -> usize { .unwrap_or(tab.history.undo_stack.len()) } +fn feed(app: &mut OpenCADStudio, text: &str) { + let _ = app.update(Message::CommandInput(text.to_string())); + let _ = app.update(Message::CommandSubmit); +} + #[test] fn local_undo_preserves_imported_native_networks_when_constraints_are_unchanged() { use codec::objects::{ @@ -922,6 +927,182 @@ fn close_all_resumes_after_discarding_a_dirty_block() { assert!(app.pending_tab_closes.is_empty()); } +#[test] +fn explode_inside_session_edits_the_definition_and_marks_dirty() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "CHILD", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(3.0, 0.0, 0.0), + ))], + ); + begin(&mut app, reference); + let _ = app.dispatch_command("-INSERT"); + feed(&mut app, "CHILD"); + feed(&mut app, "0,5"); + feed(&mut app, "1"); + feed(&mut app, "1"); + feed(&mut app, "0"); + app.finish_pending_history(i); + let before = members(&app); + assert_eq!(before.len(), 2, "the nested INSERT joins the definition"); + let insert_handle = *before.last().unwrap(); + assert!( + matches!( + app.tabs[i].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(_)) + ), + "expected the member just added to be the INSERT" + ); + let depth = main_depth(&app); + app.tabs[i].scene.select_entity(insert_handle, true); + let _ = app.dispatch_command("EXPLODE"); + assert!( + app.block_edit_dirty(i), + "the explode marks the session dirty" + ); + assert_eq!(main_depth(&app), depth, "the main stack is untouched"); + let _ = app.dispatch_command("BSAVE"); + let saved = members(&app); + assert!( + saved + .iter() + .all(|h| app.tabs[i].scene.document.get_entity(*h).is_some()), + "the saved definition carries no dangling member handle: {saved:?}" + ); + assert_eq!(saved.len(), 2, "the INSERT is replaced by its piece"); + assert!( + !saved.contains(&insert_handle), + "the exploded INSERT leaves the saved definition" + ); + assert!( + saved.iter().any(|h| { + matches!( + app.tabs[i].scene.document.get_entity(*h), + Some(EntityType::Line(_)) + ) + }), + "the child line lands as a member of the edited block" + ); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +#[test] +fn xclip_inside_session_stays_in_the_definition() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "CHILD", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(3.0, 0.0, 0.0), + ))], + ); + begin(&mut app, reference); + let _ = app.dispatch_command("-INSERT"); + feed(&mut app, "CHILD"); + feed(&mut app, "0,5"); + feed(&mut app, "1"); + feed(&mut app, "1"); + feed(&mut app, "0"); + app.finish_pending_history(i); + let before = members(&app); + let insert_handle = *before.last().unwrap(); + let depth = main_depth(&app); + app.tabs[i].scene.select_entity(insert_handle, true); + let _ = app.dispatch_command("XCLIP"); + feed(&mut app, "N"); + feed(&mut app, "R"); + feed(&mut app, "0,0"); + feed(&mut app, "10,10"); + app.finish_pending_history(i); + assert!( + crate::scene::pick::xclip::filter_handle(&app.tabs[i].scene.document, insert_handle) + .is_some(), + "the clip filter attaches to the in-block reference" + ); + assert!( + members(&app).contains(&insert_handle), + "the clipped reference stays a member of the definition" + ); + assert!(app.block_edit_dirty(i), "the clip marks the session dirty"); + assert_eq!(main_depth(&app), depth, "the main stack is untouched"); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +#[test] +fn attdef_inside_session_lands_in_the_definition_and_undo_stays_local() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + let depth = main_depth(&app); + begin(&mut app, reference); + assert_eq!(members(&app).len(), 1); + let _ = app.dispatch_command("-ATTDEF"); + feed(&mut app, ""); // modes: done + feed(&mut app, "TAG1"); + feed(&mut app, "prompt text"); + feed(&mut app, ""); // default value + feed(&mut app, ""); // single line + feed(&mut app, "0,5"); + feed(&mut app, "2"); + feed(&mut app, "0"); + app.finish_pending_history(i); + let after = members(&app); + assert_eq!(after.len(), 2, "the ATTDEF joins the definition"); + assert!( + matches!( + app.tabs[i] + .scene + .document + .get_entity(*after.last().unwrap()), + Some(EntityType::AttributeDefinition(_)) + ), + "the new member is the ATTDEF" + ); + assert!( + app.block_edit_dirty(i), + "the ATTDEF marks the session dirty" + ); + assert_eq!(main_depth(&app), depth, "the main stack is untouched"); + app.undo_steps(1); + assert_eq!(members(&app).len(), 1, "session undo removes the ATTDEF"); + assert_eq!(main_depth(&app), depth, "the main stack is still untouched"); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +#[test] +fn save_after_session_undo_does_not_write_a_dangling_member() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + circle(&mut app, 1.0); + assert_eq!(members(&app).len(), 2); + app.undo_steps(1); + assert_eq!(members(&app).len(), 1); + let depth = main_depth(&app); + let _ = app.dispatch_command("BSAVE"); + assert!(app.tabs[app.active_tab].active_block_edit.is_some()); + let saved = members(&app); + assert!( + saved.iter().all(|h| app.tabs[app.active_tab] + .scene + .document + .get_entity(*h) + .is_some()), + "save after a session undo must not write a member handle whose entity is gone: {saved:?}" + ); + assert_eq!( + main_depth(&app), + depth, + "a clean save commits no new drawing step" + ); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(members(&app).len(), 1); +} + #[test] fn locked_bedit_no_selection_opens_edit_block_definition_dialog() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 2e9f9131b..dd701dd0f 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -65,6 +65,12 @@ impl OpenCADStudio { /// layers they name, and commit everything as one undoable step. All definitions are validated before the first /// entity is added, so the batch is all-or-nothing. pub(super) fn control_entities_create(&mut self, req: &Value) -> Result, Value> { + if self.tabs[self.active_tab].scene.block_edit_block.is_some() { + return Err(failure( + "block_editor_open", + "finish the open block editor before adding entities to a block", + )); + } let list = req["entities"] .as_array() .filter(|v| !v.is_empty()) diff --git a/src/app/update/block_ops_tests.rs b/src/app/update/block_ops_tests.rs index 05a3c25a7..c8b070667 100644 --- a/src/app/update/block_ops_tests.rs +++ b/src/app/update/block_ops_tests.rs @@ -1227,6 +1227,47 @@ fn locked_mcp_block_delete_with_force_still_refuses_an_open_bedit_session() { let _ = app.dispatch_command("BEDIT_DISCARD"); } +/// Control/MCP `entities_create` must not route new entities into the open +/// block while pushing a main-stack entry (same policy as the plugin guard). +#[test] +fn locked_control_entities_create_refused_while_block_editor_open() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + assert!(bedit_open(&app)); + let depth = entity_count(&app); + let main_depth = app.tabs[app.active_tab] + .block_edit_history + .as_ref() + .map(|h| h.main.undo_stack.len()) + .unwrap_or(app.tabs[app.active_tab].history.undo_stack.len()); + let doc = app.automation_op(r#"{"protocol":1,"op":"state"}"#)["document_id"] + .as_u64() + .unwrap(); + let response = app.automation_op(&format!( + r#"{{"protocol":1,"op":"entities_create","request_id":"p11s15","document_id":{doc},"entities":[{{"type":"Point","location":[5,5]}}]}}"# + )); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["code"], "block_editor_open", "{response}"); + assert_eq!( + response["error"], "finish the open block editor before adding entities to a block", + "{response}" + ); + assert_eq!( + entity_count(&app), + depth, + "no entity may be created through the control path" + ); + let main_depth_after = app.tabs[app.active_tab] + .block_edit_history + .as_ref() + .map(|h| h.main.undo_stack.len()) + .unwrap_or(app.tabs[app.active_tab].history.undo_stack.len()); + assert_eq!(main_depth_after, main_depth, "no undo entry may be pushed"); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + // ── PURGE ──────────────────────────────────────────────────────────────── #[test] diff --git a/src/scene/block_base_point_tests.rs b/src/scene/block_base_point_tests.rs index b02a6e2cb..15ba6aefa 100644 --- a/src/scene/block_base_point_tests.rs +++ b/src/scene/block_base_point_tests.rs @@ -1255,3 +1255,71 @@ fn locked_redefine_block_raw_refuses_cycle() { "expected cycle error, got: {err}" ); } + +#[test] +fn locked_redefine_block_raw_refuses_open_block() { + let mut scene = Scene::new(); + let open_name = "REDEF_OPEN"; + let other_name = "REDEF_CLOSED"; + + scene.define_block_raw( + open_name, + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 0.0, 0.0), + ))], + ); + scene.define_block_raw( + other_name, + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(2.0, 0.0, 0.0), + ))], + ); + + let open_record = scene.document.block_records.get(open_name).unwrap().handle; + let members_before = scene + .document + .block_records + .get(open_name) + .unwrap() + .entity_handles + .clone(); + scene.block_edit_block = Some(open_record); + + let res = scene.redefine_block_raw( + open_name, + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(9.0, 9.0, 0.0), + ))], + ); + let err = res.expect_err("redefining the open block must be refused"); + assert!( + err.contains(open_name) && err.contains("Block Editor"), + "expected refusal naming the block and the editor, got: {err}" + ); + assert_eq!( + scene + .document + .block_records + .get(open_name) + .unwrap() + .entity_handles, + members_before, + "the open definition must be untouched" + ); + + let res = scene.redefine_block_raw( + other_name, + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(8.0, 0.0, 0.0), + ))], + ); + assert!(res.is_ok(), "a closed block must still redefine: {res:?}"); +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a14eea8d4..4e0358c7c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1432,6 +1432,11 @@ impl Scene { self.define_block_raw(name, base_point, entities); return Ok(()); }; + if self.block_edit_block == Some(br_handle) { + return Err(format!( + "block \"{name}\" is open in the Block Editor; close it before redefining" + )); + } let owned: Vec = self .document .entities()