forked from 1b-agent/opencadstudio-upstream
fix(bedit): refuse entities_create and redefine while a session is open
This commit is contained in:
1 parent
f7bd0ca5ee
commit
b221ce5e33
5 files changed
+301
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user