fix(bedit): refuse entities_create and redefine while a session is open

This commit is contained in:
Karim Jerbi committed 2026-10-15 06:10:51 +01:00
1 parent f7bd0ca5ee
commit b221ce5e33
5 files changed
+301

No files matched your search

+181
View File
@@ -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();
+6
View File
@@ -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<Task<Message>, 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())
+41
View File
@@ -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]
+68
View File
@@ -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:?}");
}
+5
View File
@@ -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<Handle> = self
.document
.entities()