//! Block delete / rename / purge characterisation. Tests only; //! no production code changed. //! //! Paths covered: //! * command line — `RENAME BLOCK old new` (and the absence of a //! block-definition DELETE on the command line) //! * Blocks palette — `MenuAction::Remove` (there is no rename action) //! * plugin API — `HostSession::table_operation(BlockDelete / BlockRename)` //! * control/"MCP" — `{"protocol":1,"op":"block_delete"}` //! * PURGE — `PURGE BLOCKS` //! //! Every scenario is built in code; expected values are hand-written constants. //! Naming follows the BEDIT convention: `locked_*` for behaviour we want to //! keep, `documenting_*` for behaviour we expect to change. //! //! Every path goes through the shared block service //! (`crate::scene::block_service`). The `documenting_*` cells whose behaviour //! changed on purpose were converted to `locked_*` tests pinning the new, //! shared outcome. use crate::app::OpenCADStudio; use codec::entities::{ Block as BlockEntity, BlockEnd, Dimension, DimensionAligned, Insert, LeaderContentType, Line, MultiLeader, }; use codec::objects::Dictionary; use codec::tables::{BlockRecord, DimStyle}; use codec::types::{Handle, Vector3}; use codec::EntityType; fn app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app } fn tab(app: &OpenCADStudio) -> usize { app.active_tab } fn line(start: [f64; 3], end: [f64; 3]) -> EntityType { EntityType::Line(Line::from_points( Vector3::new(start[0], start[1], start[2]), Vector3::new(end[0], end[1], end[2]), )) } /// One block definition holding a single LINE (0,0)-(1,0). fn define_block(app: &mut OpenCADStudio, name: &str) -> Handle { let i = tab(app); app.tabs[i].scene.define_block_raw( name, Vector3::ZERO, vec![line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])], ); record_handle(app, name) } /// A block definition built by hand, so `*` / xref / layout records can be /// named exactly (the codec's table does not police the `*` prefix). fn define_raw_block(app: &mut OpenCADStudio, name: &str, mut record: BlockRecord) -> Handle { let i = tab(app); let doc = &mut app.tabs[i].scene.document; let next = doc.next_handle(); let br = Handle::new(next); let block = Handle::new(next + 1); let end = Handle::new(next + 2); record.handle = br; record.block_entity_handle = block; record.block_end_handle = end; doc.block_records.add(record).unwrap(); let mut marker = BlockEntity::new(name, Vector3::ZERO); marker.common.handle = block; marker.common.owner_handle = br; doc.add_entity(EntityType::Block(marker)).unwrap(); let mut block_end = BlockEnd::new(); block_end.common.handle = end; block_end.common.owner_handle = br; doc.add_entity(EntityType::BlockEnd(block_end)).unwrap(); let mut entity = Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)); entity.common.owner_handle = br; doc.add_entity(EntityType::Line(entity)).unwrap(); br } fn record_handle(app: &OpenCADStudio, name: &str) -> Handle { app.tabs[tab(app)] .scene .document .block_records .get(name) .map(|r| r.handle) .unwrap_or_else(|| panic!("block {name} is not defined")) } fn record_exists(app: &OpenCADStudio, name: &str) -> bool { app.tabs[tab(app)] .scene .document .block_records .get(name) .is_some() } fn add_insert(app: &mut OpenCADStudio, block: &str) -> Handle { let i = tab(app); let insert = Insert::new(block.to_string(), Vector3::new(5.0, 5.0, 0.0)); app.tabs[i].scene.add_entity(EntityType::Insert(insert)) } /// INSERTs of any kind referencing `name`, in model space and inside block /// definitions alike (the document's flat entity list). fn insert_count(app: &OpenCADStudio, name: &str) -> usize { app.tabs[tab(app)] .scene .document .entities() .filter( |e| matches!(e, EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(name)), ) .count() } fn entity_count(app: &OpenCADStudio) -> usize { app.tabs[tab(app)].scene.document.entities().count() } fn errors(app: &OpenCADStudio) -> Vec { app.command_line .history .iter() .filter(|entry| entry.kind == crate::ui::command_line::EntryKind::Error) .map(|entry| entry.text.clone()) .collect() } fn outputs(app: &OpenCADStudio) -> Vec { app.command_line .history .iter() .filter(|entry| entry.kind == crate::ui::command_line::EntryKind::Output) .map(|entry| entry.text.clone()) .collect() } /// The label of the step a path pushed. `push_undo_snapshot` parks the entry /// in `history.pending` until something finishes it, so a fresh step may not /// be on the stack yet. fn last_undo_label(app: &OpenCADStudio) -> Option { let history = &app.tabs[tab(app)].history; history .undo_stack .last() .map(|snapshot| snapshot.label().to_string()) .or_else(|| { history .pending .as_ref() .map(|pending| pending.label.clone()) }) } fn undo_entry_count(app: &OpenCADStudio) -> usize { let history = &app.tabs[tab(app)].history; history.undo_stack.len() + usize::from(history.pending.is_some()) } fn undo(app: &mut OpenCADStudio) { let _ = app.update(crate::app::Message::Undo); } // ── scenario fixtures ──────────────────────────────────────────────────── /// 1 unreferenced block. fn fixture_unreferenced(app: &mut OpenCADStudio) -> String { let name = "BO_ONE"; define_block(app, name); name.to_string() } /// 1b a second unreferenced block under a different name, so a rename /// collision needs two distinct records. fn fixture_other_unreferenced(app: &mut OpenCADStudio) -> String { let name = "BO_OTHER"; define_block(app, name); name.to_string() } /// 2 referenced by a model-space INSERT. fn fixture_model_space_reference(app: &mut OpenCADStudio) -> String { let name = "BO_TWO"; define_block(app, name); add_insert(app, name); name.to_string() } /// 3 referenced only by an INSERT nested inside another definition. fn fixture_nested_reference(app: &mut OpenCADStudio) -> String { let name = "BO_INNER"; let outer = "BO_OUTER"; define_block(app, name); let inner_insert = Insert::new(name.to_string(), Vector3::ZERO); let i = tab(app); app.tabs[i].scene.define_block_raw( outer, Vector3::ZERO, vec![EntityType::Insert(inner_insert)], ); name.to_string() } /// 4 referenced by a dimension's arrow block (the dimension-style reference /// family, the same relationship a table's anonymous block has with its /// contents). fn fixture_dimension_reference(app: &mut OpenCADStudio) -> String { let name = "BO_ARROW"; let br = define_block(app, name); let i = tab(app); let mut style = DimStyle::new("BO_ARROWSTYLE"); style.dimblk = br; app.tabs[i].scene.document.dim_styles.add(style).unwrap(); let mut dimension = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); dimension.base.style_name = "BO_ARROWSTYLE".to_string(); app.tabs[i] .scene .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))); name.to_string() } /// 4b a dimension-style arrow block held by handle *and* by name, so a rename /// has to follow the name-based reference too (a handle-based one survives on /// its own). fn fixture_dimension_name_reference(app: &mut OpenCADStudio) -> String { let name = "BO_ARROWNAME"; let br = define_block(app, name); let i = tab(app); let mut style = DimStyle::new("BO_ARROWNAMESTYLE"); style.dimblk = br; style.dimblk_name = name.to_string(); app.tabs[i].scene.document.dim_styles.add(style).unwrap(); let mut dimension = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); dimension.base.style_name = "BO_ARROWNAMESTYLE".to_string(); app.tabs[i] .scene .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))); name.to_string() } /// 6 a multi-leader's block content. fn fixture_multileader_reference(app: &mut OpenCADStudio) -> String { let name = "BO_ML"; let br = define_block(app, name); let i = tab(app); let mut leader = MultiLeader::new(); leader.content_type = LeaderContentType::Block; leader.context.has_block_contents = true; leader.block_content_handle = Some(br); app.tabs[i] .scene .add_entity(EntityType::MultiLeader(Box::new(leader))); name.to_string() } /// 5 anonymous block, unreferenced. fn fixture_anonymous(app: &mut OpenCADStudio) -> String { let name = "*BO_ANON"; define_raw_block(app, name, BlockRecord::new(name.to_string())); name.to_string() } /// 8 a layout (*) block record, under the name the table stores it as (the /// service reports the stored name in its messages). fn fixture_layout(app: &mut OpenCADStudio) -> String { app.tabs[tab(app)] .scene .document .block_records .get("*Model_Space") .map(|record| record.name.clone()) .expect("a model space record") } /// 9 xref block. fn fixture_xref(app: &mut OpenCADStudio) -> String { let name = "BO_XREF"; define_block(app, name); let i = tab(app); app.tabs[i] .scene .document .block_records .get_mut(name) .unwrap() .flags .is_xref = true; name.to_string() } /// 10 a dynamic block: the record's extension dictionary carries an /// `ACAD_ENHANCEDBLOCK` entry whose variants dictionary sources a `*U` /// anonymous record. Both records must survive every delete and purge path. fn attach_dynamic_block(app: &mut OpenCADStudio, record: &str) -> (Handle, Handle) { let record_handle = record_handle(app, record); // The *U anonymous record the variants dictionary sources. let variant_handle = define_raw_block( app, "*U_BO_VARIANT", BlockRecord::new("*U_BO_VARIANT".to_string()), ); let i = tab(app); let doc = &mut app.tabs[i].scene.document; let xdict = doc.allocate_handle(); let variants = doc.allocate_handle(); let mut dictionary = Dictionary::new(); dictionary.handle = xdict; dictionary.owner = record_handle; dictionary.add_entry("ACAD_ENHANCEDBLOCK", variants); doc.objects .insert(xdict, codec::objects::ObjectType::Dictionary(dictionary)); let mut variants_dictionary = Dictionary::new(); variants_dictionary.handle = variants; variants_dictionary.owner = xdict; variants_dictionary.add_entry("VisibilityState1", variant_handle); doc.objects.insert( variants, codec::objects::ObjectType::Dictionary(variants_dictionary), ); (record_handle, variant_handle) } /// A fixture dynamic block: `BO_DYN` plus its `*U` variant record. fn fixture_dynamic_block(app: &mut OpenCADStudio) -> String { let name = "BO_DYN"; define_block(app, name); attach_dynamic_block(app, name); name.to_string() } // ── path drivers ───────────────────────────────────────────────────────── fn palette_remove(app: &mut OpenCADStudio, name: &str) { let _ = app.on_block_palette(crate::ui::window::block_palette::BlockPaletteMsg::Menu( crate::ui::window::block_palette::Item::Current(name.to_string()), crate::ui::window::block_palette::MenuAction::Remove, )); } #[cfg(not(target_arch = "wasm32"))] fn plugin_block_delete(app: &mut OpenCADStudio, name: &str) -> Result { use ocs_plugin_api::host::TableOperation; let i = tab(app); let mut host = crate::app::plugin_host::HostSession::new(app, i); host.table_operation(TableOperation::BlockDelete { name: name.to_string(), }) } #[cfg(not(target_arch = "wasm32"))] fn plugin_block_rename(app: &mut OpenCADStudio, from: &str, to: &str) -> Result { use ocs_plugin_api::host::TableOperation; let i = tab(app); let mut host = crate::app::plugin_host::HostSession::new(app, i); host.table_operation(TableOperation::BlockRename { from: from.to_string(), to: to.to_string(), }) } fn control_request(app: &mut OpenCADStudio, mut req: serde_json::Value) -> serde_json::Value { use serde_json::json; static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); let state = app.control_state(); req["protocol"] = json!(1); req["document_id"] = state["document_id"].clone(); req["revision"] = state["revision"].clone(); req["client_id"] = json!("test"); if req.get("request_id").is_none() { let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); req["request_id"] = json!(format!("req-{id}")); } let (response, task) = app.control_request(req.clone()); app.drive_headless_task(task).unwrap(); if matches!(response["status"].as_str(), Some("accepted" | "running")) { app.control_request(json!({"op":"operation","request_id":req["request_id"]})) .0 } else { response } } fn mcp_block_delete(app: &mut OpenCADStudio, name: &str) -> serde_json::Value { use serde_json::json; let req = json!({"op":"block_delete","name":name}); control_request(app, req) } /// `"force": true` — the protocol change: it erases the /// referencing INSERTs in the same undo step instead of refusing. fn mcp_block_delete_forced(app: &mut OpenCADStudio, name: &str) -> serde_json::Value { use serde_json::json; let req = json!({"op":"block_delete","name":name,"force":true}); control_request(app, req) } fn begin_bedit(app: &mut OpenCADStudio, handle: Handle) { let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())); } /// The exact text the shared service formatter produces, asserted by every /// path so the matrix cannot drift apart again. fn referenced_text(name: &str, count: usize) -> String { format!("block \"{name}\" is referenced {count} time(s) in the drawing and cannot be removed") } /// What the command line shows for one of the service's messages: the text /// itself plus the `INVALID:` decoration `CommandLine::push_error` adds. The /// palette and `RENAME` print through that line, so their cells carry the /// decoration; the plugin and control paths hand the raw text back. fn command_line_error(text: &str) -> String { format!("✕ INVALID: {text}") } fn bedit_open(app: &OpenCADStudio) -> bool { app.tabs[tab(app)].scene.block_edit_block.is_some() } // ── command line: RENAME ───────────────────────────────────────────────── #[test] fn locked_rename_block_repoints_inserts_and_undo_restores_the_name() { let mut app = app(); let name = fixture_unreferenced(&mut app); let insert = add_insert(&mut app, &name); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ONE_RENAMED")); assert!( !errors(&app).iter().any(|e| e.contains("RENAME")), "unexpected RENAME error: {:?}", errors(&app) ); assert!(record_exists(&app, "BO_ONE_RENAMED")); assert!(!record_exists(&app, &name)); let repointed = app.tabs[tab(&app)] .scene .document .get_entity(insert) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }); assert_eq!(repointed.as_deref(), Some("BO_ONE_RENAMED")); assert_eq!( last_undo_label(&app).as_deref(), Some("Rename block BO_ONE to BO_ONE_RENAMED") ); assert!( outputs(&app) .iter() .any(|o| o.contains("RENAME") && o.contains("BO_ONE_RENAMED")), "no RENAME success line: {:?}", outputs(&app) ); undo(&mut app); assert!(record_exists(&app, &name)); assert!(!record_exists(&app, "BO_ONE_RENAMED")); let restored = app.tabs[tab(&app)] .scene .document .get_entity(insert) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }); assert_eq!(restored.as_deref(), Some(name.as_str())); } #[test] fn locked_rename_to_an_existing_name_is_refused_with_the_shared_text_leaving_no_undo_entry() { let mut app = app(); let name = fixture_unreferenced(&mut app); let other = fixture_other_unreferenced(&mut app); let before = undo_entry_count(&app); let insert_count_before = insert_count(&app, &name); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} {other}")); assert!(record_exists(&app, &name)); assert!(record_exists(&app, &other)); assert_eq!(insert_count(&app, &name), insert_count_before); assert_eq!( undo_entry_count(&app), before, "a refused RENAME must not leave an undo entry" ); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{other}\" already exists" ))] ); assert!( !outputs(&app).iter().any(|o| o.contains("RENAME")), "a success line was printed for a refused rename: {:?}", outputs(&app) ); } #[test] fn locked_rename_to_an_invalid_name_is_refused_with_the_shared_text() { let mut app = app(); let name = fixture_unreferenced(&mut app); let before = undo_entry_count(&app); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} bad/name")); assert!(record_exists(&app, &name)); assert!(!record_exists(&app, "bad/name")); assert_eq!(undo_entry_count(&app), before); assert_eq!( errors(&app), vec![command_line_error("a block name cannot contain '/'")] ); } /// A rename follows a name-based reference wherever it sits: an INSERT nested /// inside another definition repoints like a model-space one. #[test] fn locked_rename_follows_an_insert_nested_inside_another_definition() { let mut app = app(); let name = fixture_nested_reference(&mut app); let nested = app.tabs[tab(&app)] .scene .document .entities() .find(|e| matches!(e, EntityType::Insert(ins) if ins.block_name == name)) .map(|e| e.common().handle) .expect("the nested INSERT"); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_INNER_RENAMED")); assert!(record_exists(&app, "BO_INNER_RENAMED")); assert!(!record_exists(&app, &name)); let repointed = app.tabs[tab(&app)] .scene .document .get_entity(nested) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }); assert_eq!(repointed.as_deref(), Some("BO_INNER_RENAMED")); assert_eq!( last_undo_label(&app).as_deref(), Some("Rename block BO_INNER to BO_INNER_RENAMED") ); undo(&mut app); assert!(record_exists(&app, &name)); let restored = app.tabs[tab(&app)] .scene .document .get_entity(nested) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }); assert_eq!(restored.as_deref(), Some(name.as_str())); } /// A dimension-style arrow block held by name follows the rename too; the /// handle-based reference keeps working because the record keeps its handle. #[test] fn locked_rename_repoints_a_dimension_arrow_block_name() { let mut app = app(); let name = fixture_dimension_name_reference(&mut app); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ARROWNAME_NEW")); assert!(record_exists(&app, "BO_ARROWNAME_NEW")); assert!(!record_exists(&app, &name)); let i = tab(&app); let style = app.tabs[i] .scene .document .dim_styles .get("BO_ARROWNAMESTYLE") .unwrap(); assert_eq!(style.dimblk_name, "BO_ARROWNAME_NEW"); assert!( style.dimblk.is_valid(), "the handle-based arrow was dropped" ); assert_eq!( last_undo_label(&app).as_deref(), Some("Rename block BO_ARROWNAME to BO_ARROWNAME_NEW") ); undo(&mut app); assert!(record_exists(&app, &name)); assert_eq!( app.tabs[tab(&app)] .scene .document .dim_styles .get("BO_ARROWNAMESTYLE") .unwrap() .dimblk_name, name ); } /// A rename refuses the protected records with the shared text, one refusal /// per record and no undo entry for either. #[test] fn locked_rename_refuses_anonymous_and_layout_records_with_the_shared_text() { let mut app = app(); let anonymous = fixture_anonymous(&mut app); let layout = fixture_layout(&mut app); let before = undo_entry_count(&app); let _ = app.dispatch_command(&format!("RENAME BLOCK {anonymous} BO_NEW")); let _ = app.dispatch_command(&format!("RENAME BLOCK {layout} BO_NEW")); assert!(record_exists(&app, &anonymous)); assert!(record_exists(&app, &layout)); assert!(!record_exists(&app, "BO_NEW")); assert_eq!(undo_entry_count(&app), before); assert_eq!( errors(&app), vec![ command_line_error(&format!("block \"{anonymous}\" is an anonymous block")), command_line_error(&format!( "block \"{layout}\" is a model or paper space layout record" )), ] ); } /// A rename refuses a block with an open BEDIT session, whatever the new name. #[test] fn locked_rename_refuses_a_block_with_an_open_bedit_session() { let mut app = app(); let name = fixture_unreferenced(&mut app); let insert = add_insert(&mut app, &name); begin_bedit(&mut app, insert); let before = undo_entry_count(&app); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ONE_RENAMED")); assert!(record_exists(&app, &name)); assert!(!record_exists(&app, "BO_ONE_RENAMED")); assert_eq!(undo_entry_count(&app), before); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" is open in the block editor; close the editor first" ))] ); let _ = app.dispatch_command("BEDIT_DISCARD"); } /// A handle-based reference (a multi-leader's block content, a /// dimension-style arrow handle) does not block a rename: the record keeps its /// handle, so the reference keeps resolving. #[test] fn locked_rename_succeeds_when_only_a_handle_based_reference_exists() { let mut app = app(); let name = fixture_multileader_reference(&mut app); let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ML_RENAMED")); assert!(record_exists(&app, "BO_ML_RENAMED")); assert!(!record_exists(&app, &name)); assert_eq!( last_undo_label(&app).as_deref(), Some("Rename block BO_ML to BO_ML_RENAMED") ); let content = app.tabs[tab(&app)] .scene .document .entities() .find_map(|e| match e { EntityType::MultiLeader(ml) => ml.block_content_handle, _ => None, }) .expect("the multi-leader's block content"); let renamed = app.tabs[tab(&app)] .scene .document .block_records .get("BO_ML_RENAMED") .unwrap(); assert_eq!( renamed.handle, content, "the handle-based reference no longer resolves" ); undo(&mut app); assert!(record_exists(&app, &name)); } #[test] fn documenting_command_line_has_no_block_definition_delete() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let before = undo_entry_count(&app); for command in ["DELETE", "BDELETE", "BLOCKDELETE", "PURGEBLOCK"] { let _ = app.dispatch_command(command); } assert!( record_exists(&app, &name), "a command-line block delete would have removed {name}" ); assert_eq!( undo_entry_count(&app), before, "no command created an undo entry for a block delete" ); assert!(insert_count(&app, &name) > 0); // read from code: DELETE is a shortcut for DELETESELECTED // (src/app/shortcuts.rs:50) and ERASE erases selected entities // (src/app/commands/draw.rs:933); no command name deletes a definition. } // ── Blocks palette ─────────────────────────────────────────────────────── #[test] fn locked_palette_remove_deletes_an_unreferenced_block_with_a_delete_undo_label() { let mut app = app(); let name = fixture_unreferenced(&mut app); palette_remove(&mut app, &name); assert!(!record_exists(&app, &name), "the definition survived"); assert_eq!( last_undo_label(&app).as_deref(), Some("Delete block BO_ONE") ); assert!( outputs(&app).iter().any(|o| o.contains("removed")), "no palette success line: {:?}", outputs(&app) ); undo(&mut app); assert!(record_exists(&app, &name)); } #[test] fn locked_palette_remove_refuses_a_model_space_referenced_block() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let entities_before = entity_count(&app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!(record_exists(&app, &name), "the definition was removed"); assert_eq!(insert_count(&app, &name), 1, "the INSERT was erased"); assert_eq!(entity_count(&app), entities_before); assert_eq!( undo_entry_count(&app), undo_before, "an undo entry was pushed" ); assert_eq!( errors(&app), vec![command_line_error(&referenced_text(&name, 1))], "the palette must show the shared refusal text" ); } #[test] fn locked_palette_remove_refuses_a_block_referenced_from_inside_a_definition() { let mut app = app(); let name = fixture_nested_reference(&mut app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!(record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 1); assert_eq!(undo_entry_count(&app), undo_before); assert_eq!( errors(&app), vec![command_line_error(&referenced_text(&name, 1))], "a nested INSERT is a reference like any other" ); } #[test] fn locked_palette_remove_refuses_a_multileader_block_content_reference() { let mut app = app(); let name = fixture_multileader_reference(&mut app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!(record_exists(&app, &name)); assert_eq!( errors(&app), vec![command_line_error(&referenced_text(&name, 1))], "a multi-leader's block content is a reference like any other" ); assert_eq!(undo_entry_count(&app), undo_before); } #[test] fn locked_palette_remove_refuses_a_dimension_arrow_block_reference() { let mut app = app(); let name = fixture_dimension_reference(&mut app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!( record_exists(&app, &name), "the palette deleted a block a dimension style still points at" ); assert_eq!( errors(&app), vec![command_line_error(&referenced_text(&name, 2))], "the dimension's arrow-block handle and the picture are references" ); assert_eq!(undo_entry_count(&app), undo_before); } #[test] fn locked_palette_remove_refuses_an_anonymous_block() { let mut app = app(); let name = fixture_anonymous(&mut app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!( record_exists(&app, &name), "the palette deleted an anonymous block" ); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" is an anonymous block" ))] ); assert_eq!(undo_entry_count(&app), undo_before); } #[test] fn locked_palette_remove_refuses_the_model_space_record() { let mut app = app(); let name = fixture_layout(&mut app); palette_remove(&mut app, &name); assert!( record_exists(&app, &name), "the palette removed the model space record" ); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" is a model or paper space layout record" ))] ); } #[test] fn locked_palette_remove_refuses_an_xref_block() { let mut app = app(); let name = fixture_xref(&mut app); palette_remove(&mut app, &name); assert!(record_exists(&app, &name), "the palette deleted the xref"); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" is an externally referenced block" ))] ); } #[test] fn locked_palette_remove_refuses_a_dynamic_block() { let mut app = app(); let name = fixture_dynamic_block(&mut app); let undo_before = undo_entry_count(&app); palette_remove(&mut app, &name); assert!( record_exists(&app, &name), "the palette deleted a dynamic block" ); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" holds dynamic-block data" ))] ); assert_eq!(undo_entry_count(&app), undo_before); } #[test] fn locked_palette_remove_refuses_a_block_with_an_open_bedit_session() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let insert = add_insert(&mut app, &name); begin_bedit(&mut app, insert); assert!(bedit_open(&app), "BEDIT did not start"); let undo_before = undo_entry_count(&app); // The session is the reason for the refusal, not the INSERT it edits. palette_remove(&mut app, &name); assert!(record_exists(&app, &name)); assert_eq!(undo_entry_count(&app), undo_before); assert_eq!( errors(&app), vec![command_line_error(&format!( "block \"{name}\" is open in the block editor; close the editor first" ))] ); } /// Nested BEDIT sessions all count: the outer and the inner record are both /// open, so both are refused — the whole session stack is handed to the /// service, not just the active tab. #[test] fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() { let mut app = app(); let inner = "BO_NEST_INNER"; define_block(&mut app, inner); let outer = "BO_NEST_OUTER"; let nested_insert = Insert::new(inner.to_string(), Vector3::ZERO); let i = tab(&app); app.tabs[i].scene.define_block_raw( outer, Vector3::ZERO, vec![EntityType::Insert(nested_insert)], ); let outer_reference = add_insert(&mut app, outer); let inner_reference = app.tabs[i] .scene .document .entities() .find(|e| matches!(e, EntityType::Insert(ins) if ins.block_name == inner)) .map(|e| e.common().handle) .expect("the nested INSERT"); begin_bedit(&mut app, outer_reference); begin_bedit(&mut app, inner_reference); assert_eq!( app.tabs[tab(&app)].block_edits.len(), 2, "the nested session did not open" ); for name in [outer, inner] { palette_remove(&mut app, name); assert!(record_exists(&app, name), "{name} went while open"); let expected = command_line_error(&format!( "block \"{name}\" is open in the block editor; close the editor first" )); assert_eq!( errors(&app).last(), Some(&expected), "{name} was refused with the wrong text" ); } let _ = app.dispatch_command("BEDIT_DISCARD"); } // ── plugin API ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_removes_an_unreferenced_block_and_undo_restores_it() { let mut app = app(); let name = fixture_unreferenced(&mut app); let before = entity_count(&app); assert!(plugin_block_delete(&mut app, &name).is_ok()); assert!(!record_exists(&app, &name)); assert!(entity_count(&app) < before, "the block's entities survived"); assert_eq!( last_undo_label(&app).as_deref(), Some("Delete block BO_ONE") ); undo(&mut app); assert!(record_exists(&app, &name)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_refuses_a_model_space_reference() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let undo_before = undo_entry_count(&app); let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); assert_eq!(err, referenced_text(&name, 1)); assert!(record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 1); assert_eq!(undo_entry_count(&app), undo_before); } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_refuses_a_block_referenced_only_inside_a_definition() { let mut app = app(); let name = fixture_nested_reference(&mut app); let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); assert_eq!(err, referenced_text(&name, 1)); assert!(record_exists(&app, &name)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_refuses_a_dimension_arrow_block() { let mut app = app(); let name = fixture_dimension_reference(&mut app); let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); assert_eq!(err, referenced_text(&name, 2)); assert!(record_exists(&app, &name)); } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_refuses_anonymous_layout_and_xref_blocks() { let mut app = app(); let anonymous = fixture_anonymous(&mut app); let err = plugin_block_delete(&mut app, &anonymous).expect_err("should refuse"); assert_eq!(err, format!("block \"{anonymous}\" is an anonymous block")); assert!(record_exists(&app, &anonymous)); let xref = fixture_xref(&mut app); let err = plugin_block_delete(&mut app, &xref).expect_err("should refuse"); assert_eq!( err, format!("block \"{xref}\" is an externally referenced block") ); assert!(record_exists(&app, &xref)); let layout = fixture_layout(&mut app); let err = plugin_block_delete(&mut app, &layout).expect_err("should refuse"); assert_eq!( err, format!("block \"{layout}\" is a model or paper space layout record") ); assert!(record_exists(&app, &layout)); } /// The plugin API renames through the same service, so its refusals carry the /// same text as the command line's and the palette's. #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_rename_refuses_anonymous_layout_and_xref_records() { let mut app = app(); let anonymous = fixture_anonymous(&mut app); let err = plugin_block_rename(&mut app, &anonymous, "BO_PLUGIN_RENAMED").expect_err("should refuse"); assert_eq!(err, format!("block \"{anonymous}\" is an anonymous block")); let xref = fixture_xref(&mut app); let err = plugin_block_rename(&mut app, &xref, "BO_PLUGIN_RENAMED").expect_err("should refuse"); assert_eq!( err, format!("block \"{xref}\" is an externally referenced block") ); let layout = fixture_layout(&mut app); let err = plugin_block_rename(&mut app, &layout, "BO_PLUGIN_RENAMED").expect_err("should refuse"); assert_eq!( err, format!("block \"{layout}\" is a model or paper space layout record") ); for name in [anonymous, xref, layout] { assert!(record_exists(&app, &name), "{name} was renamed"); } } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_rename_follows_inserts_and_refuses_taken_or_invalid_names() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let insert = add_insert(&mut app, &name); let other = fixture_unreferenced(&mut app); assert!(plugin_block_rename(&mut app, &name, "BO_PLUGIN_RENAMED").is_ok()); assert_eq!( last_undo_label(&app).as_deref(), Some("Rename block BO_TWO to BO_PLUGIN_RENAMED") ); assert!(!record_exists(&app, &name)); let repointed = app.tabs[tab(&app)] .scene .document .get_entity(insert) .and_then(|e| match e { EntityType::Insert(ins) => Some(ins.block_name.clone()), _ => None, }); assert_eq!(repointed.as_deref(), Some("BO_PLUGIN_RENAMED")); let before = undo_entry_count(&app); let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", &other) .expect_err("should refuse a taken name"); assert_eq!(err, format!("block \"{other}\" already exists")); let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", "bad/name") .expect_err("should refuse an invalid name"); assert_eq!(err, "a block name cannot contain '/'"); let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", "") .expect_err("should refuse an empty name"); assert_eq!(err, "a block name cannot be empty"); assert_eq!(undo_entry_count(&app), before); } #[cfg(not(target_arch = "wasm32"))] #[test] fn locked_plugin_block_delete_refuses_a_block_with_an_open_bedit_session() { let mut app = app(); let name = fixture_unreferenced(&mut app); // A BEDIT session can only be started on a reference, so insert one. let insert = add_insert(&mut app, &name); begin_bedit(&mut app, insert); let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); assert_eq!( err, format!("block \"{name}\" is open in the block editor; close the editor first") ); let _ = app.dispatch_command("BEDIT_DISCARD"); } // ── control ("MCP") block_delete ───────────────────────────────────────── #[test] fn locked_mcp_block_delete_refuses_a_referenced_block_without_force() { let mut app = app(); let name = fixture_model_space_reference(&mut app); add_insert(&mut app, &name); let undo_before = undo_entry_count(&app); let response = mcp_block_delete(&mut app, &name); assert_eq!(response["ok"], false, "{response}"); assert_eq!(response["code"], "block_referenced", "{response}"); assert_eq!(response["error"], referenced_text(&name, 2)); assert!(record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 2); assert_eq!(undo_entry_count(&app), undo_before); } #[test] fn locked_mcp_block_delete_with_force_erases_referencing_inserts_and_undo_restores_them() { let mut app = app(); let name = fixture_model_space_reference(&mut app); let insert = add_insert(&mut app, &name); let response = mcp_block_delete_forced(&mut app, &name); assert_eq!(response["ok"], true, "{response}"); assert!(!record_exists(&app, &name), "the definition survived"); assert_eq!(insert_count(&app, &name), 0, "the INSERTs survived"); assert!(app.tabs[tab(&app)] .scene .document .get_entity(insert) .is_none()); assert_eq!( last_undo_label(&app).as_deref(), Some(format!("Delete block {name}").as_str()) ); undo(&mut app); assert!(record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 2); assert!(app.tabs[tab(&app)] .scene .document .get_entity(insert) .is_some()); } #[test] fn locked_mcp_block_delete_with_force_erases_inserts_nested_in_other_definitions() { let mut app = app(); let name = fixture_nested_reference(&mut app); let response = mcp_block_delete_forced(&mut app, &name); assert_eq!(response["ok"], true, "{response}"); assert!(!record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 0, "the nested INSERT survived"); undo(&mut app); assert!(record_exists(&app, &name)); assert_eq!(insert_count(&app, &name), 1); } #[test] fn locked_mcp_block_delete_refuses_layout_xref_and_anonymous_records() { let mut app = app(); let layout = fixture_layout(&mut app); let response = mcp_block_delete(&mut app, &layout); assert_eq!(response["code"], "block_protected", "{response}"); assert!(record_exists(&app, &layout)); let xref = fixture_xref(&mut app); let response = mcp_block_delete_forced(&mut app, &xref); assert_eq!(response["code"], "block_protected", "{response}"); assert!(record_exists(&app, &xref), "the xref definition survived"); let anonymous = fixture_anonymous(&mut app); let response = mcp_block_delete_forced(&mut app, &anonymous); assert_eq!(response["code"], "block_protected", "{response}"); assert!(record_exists(&app, &anonymous)); } #[test] fn locked_mcp_block_delete_refuses_a_dimension_arrow_block() { let mut app = app(); let name = fixture_dimension_reference(&mut app); let response = mcp_block_delete(&mut app, &name); assert_eq!(response["code"], "block_referenced", "{response}"); assert_eq!(response["error"], referenced_text(&name, 2), "{response}"); assert!(record_exists(&app, &name)); assert!( app.tabs[tab(&app)] .scene .document .dim_styles .iter() .any(|style| style.dimblk.is_valid()), "the dimension style kept its arrow block handle" ); } #[test] fn locked_mcp_block_delete_reports_an_unknown_block() { let mut app = app(); let response = mcp_block_delete(&mut app, "BO_NOPE"); assert_eq!(response["code"], "block_missing", "{response}"); } #[test] fn locked_mcp_block_delete_refuses_a_block_with_an_open_bedit_session() { 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 response = mcp_block_delete(&mut app, &name); assert_eq!(response["ok"], false, "{response}"); assert_eq!(response["code"], "block_in_edit", "{response}"); assert_eq!( response["error"], format!("block \"{name}\" is open in the block editor; close the editor first") ); assert!(record_exists(&app, &name)); let _ = app.dispatch_command("BEDIT_DISCARD"); } /// `force` erases referencing INSERTs, not the other protections: an open /// BEDIT session still refuses. #[test] fn locked_mcp_block_delete_with_force_still_refuses_an_open_bedit_session() { let mut app = app(); let name = fixture_unreferenced(&mut app); let insert = add_insert(&mut app, &name); begin_bedit(&mut app, insert); let response = mcp_block_delete_forced(&mut app, &name); assert_eq!(response["ok"], false, "{response}"); assert_eq!(response["code"], "block_in_edit", "{response}"); assert!(record_exists(&app, &name)); let _ = app.dispatch_command("BEDIT_DISCARD"); } // ── PURGE ──────────────────────────────────────────────────────────────── #[test] fn locked_purge_removes_an_unreferenced_block_and_undo_restores_it() { let mut app = app(); let name = fixture_unreferenced(&mut app); let before = entity_count(&app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!(!record_exists(&app, &name), "the definition survived"); assert!(entity_count(&app) < before); assert_eq!(last_undo_label(&app).as_deref(), Some("Purge")); assert!( outputs(&app).iter().any(|o| o.contains("PURGE:")), "no PURGE success line: {:?}", outputs(&app) ); undo(&mut app); assert!(record_exists(&app, &name)); } #[test] fn locked_purge_keeps_a_model_space_referenced_block() { let mut app = app(); let model_space = fixture_model_space_reference(&mut app); let dimension = fixture_dimension_reference(&mut app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!( record_exists(&app, &model_space), "purged a model-space referenced block" ); assert_eq!(insert_count(&app, &model_space), 1); assert!( record_exists(&app, &dimension), "purged a dimension arrow block" ); } #[test] fn locked_purge_removes_a_block_referenced_only_inside_an_unreferenced_definition() { // An INSERT inside an unreferenced definition is a reference like any // other, so the inner block is skipped on the first pass; the outer // definition goes (nothing references it), and only once it is gone does // the inner block qualify on the next pass. Both records end up removed, // exactly as before the shared service, but now for one shared reason. let mut app = app(); let nested = fixture_nested_reference(&mut app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!( !record_exists(&app, &nested), "the nested-only block was kept" ); assert!( !record_exists(&app, "BO_OUTER"), "the outer definition was kept" ); assert_eq!(insert_count(&app, &nested), 0); } #[test] fn locked_purge_removes_an_unreferenced_anonymous_block() { let mut app = app(); let name = fixture_anonymous(&mut app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!(!record_exists(&app, &name), "the anonymous block survived"); } /// The unsafe anonymous case: an INSERT references the anonymous record, so /// PURGE keeps it exactly like any other referenced block. #[test] fn locked_purge_keeps_a_referenced_anonymous_block() { let mut app = app(); let name = fixture_anonymous(&mut app); add_insert(&mut app, &name); let _ = app.dispatch_command("PURGE BLOCKS"); assert!( record_exists(&app, &name), "purge removed a referenced anonymous block" ); assert_eq!(insert_count(&app, &name), 1); } /// A dynamic block and the `*U` record its variants dictionary sources are /// both unreferenced, yet both stay: without the dictionary walk the /// anonymous `*U` record would qualify on its own. #[test] fn locked_purge_keeps_a_dynamic_block_and_its_u_variant() { let mut app = app(); let name = fixture_dynamic_block(&mut app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!(record_exists(&app, &name), "purged a dynamic block"); assert!( record_exists(&app, "*U_BO_VARIANT"), "purged the *U record the dynamic block sources" ); } #[test] fn locked_purge_keeps_xref_and_layout_records() { let mut app = app(); let xref = fixture_xref(&mut app); let layout = fixture_layout(&mut app); let _ = app.dispatch_command("PURGE BLOCKS"); assert!(record_exists(&app, &xref), "purged an xref definition"); assert!(record_exists(&app, &layout), "purged a layout record"); } #[test] fn locked_purge_skips_a_block_with_an_open_bedit_session() { let mut app = app(); let name = fixture_unreferenced(&mut app); let insert = add_insert(&mut app, &name); begin_bedit(&mut app, insert); let _ = app.dispatch_command("PURGE BLOCKS"); // The BEDIT session's own reference keeps the block live through the // edited block record, so the purge sees it as reachable. assert!(record_exists(&app, &name)); let _ = app.dispatch_command("BEDIT_DISCARD"); }