Files
OneCAD-Draft/src/app/block_editor_tests.rs
T
Karim Jerbi f67f2cf4f5 feat(blocks): persist independent Move and Scale reference grips
Selected drawing references now expose action parameter grips outside Test
Block. Point input evaluates immutable definition geometry into an independent
anonymous representation and stores per-INSERT values in native AppDataCache
XRecords, preserving definition graphs and sibling references.

Reuse visibility representation isolation, including shared COPY blocks, and
retain cache metadata. Record geometry, INSERT links, new block records and
parameter values in one undo step. Support transformed uniform references and
Point, Linear, Polar and XY endpoints for the existing Move/Scale actions.

Validate synthetic DWG/DXF persistence, re-edit/resave, COPY isolation,
XY/Polar cache layouts, metadata, cancellation, transformed grips and undo/redo.
327 block regressions pass (6 existing opt-in checks ignored).
2026-10-17 08:48:26 +01:00

3184 lines
110 KiB
Rust

use super::{Message, ModalKind, OpenCADStudio};
use codec::entities::{Insert, Line};
use codec::types::Vector3;
use codec::{EntityType, Handle};
fn fixture() -> (OpenCADStudio, Handle) {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
"EDIT_ME",
Vector3::ZERO,
vec![EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::UNIT_X,
))],
);
let reference = app.tabs[i]
.scene
.document
.add_entity(EntityType::Insert(Insert::new("EDIT_ME", Vector3::ZERO)))
.unwrap();
app.tabs[i].dirty = false;
(app, reference)
}
fn begin(app: &mut OpenCADStudio, reference: Handle) {
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value()));
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
}
fn circle(app: &mut OpenCADStudio, x: f64) {
let result = app.automation_op(&format!(r#"{{"op":"run","cmd":"CIRCLE {x},0 2"}}"#));
assert_eq!(result["ok"], true, "{result}");
app.finish_pending_history(app.active_tab);
}
fn members(app: &OpenCADStudio) -> Vec<Handle> {
app.tabs[app.active_tab]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.entity_handles
.clone()
}
fn main_depth(app: &OpenCADStudio) -> usize {
let tab = &app.tabs[app.active_tab];
tab.block_edit_history
.as_ref()
.map(|h| h.main.undo_stack.len())
.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 block_actions_drawing_grips_history_cancel_transform_and_reopen() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone();
let targets = members(&app);
actions::create(
&mut app.tabs[i].scene.document,
def,
Kind::Move,
p.handle,
&targets,
glam::DVec3::ZERO,
)
.unwrap();
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
let sibling = app.tabs[i]
.scene
.document
.add_entity(EntityType::Insert(Insert::new(
"EDIT_ME",
Vector3::new(40., 0., 0.),
)))
.unwrap();
let EntityType::Insert(ins) = app.tabs[i]
.scene
.document
.get_entity_mut(reference)
.unwrap()
else {
panic!()
};
ins.insert_point = Vector3::new(20., 30., 0.);
ins.rotation = std::f64::consts::FRAC_PI_2;
ins.set_x_scale(2.);
ins.set_y_scale(2.);
ins.set_z_scale(2.);
let original = app.tabs[i].scene.document.clone();
app.tabs[i].scene.selected.insert(reference);
app.tabs[i].selected_handle = Some(reference);
app.refresh_properties();
let grip = app.tabs[i]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap()
.clone();
assert!(grip.world.distance(glam::DVec3::new(20., 50., 0.)) < 1e-8);
let depth = app.tabs[i].history.undo_stack.len();
assert!(app.start_test_action_grip(reference, grip.id));
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, original);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
// Escape also clears the ordinary drawing selection.
app.tabs[i].scene.selected.insert(reference);
app.refresh_properties();
assert!(app.start_test_action_grip(reference, grip.id));
feed(&mut app, "20,70");
assert!(app.tabs[i].active_cmd.is_none());
let changed = app.tabs[i].scene.document.clone();
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
let EntityType::Insert(ins) = changed.get_entity(reference).unwrap() else {
panic!()
};
assert_ne!(ins.block_name, "EDIT_ME");
let member = changed
.block_records
.get(&ins.block_name)
.unwrap()
.entity_handles[0];
let EntityType::Line(line) = changed.get_entity(member).unwrap() else {
panic!()
};
assert!((line.start.x - 10.).abs() < 1e-8);
assert_eq!(
changed.get_entity(targets[0]),
original.get_entity(targets[0])
);
assert_eq!(changed.get_entity(sibling), original.get_entity(sibling));
app.undo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(reference),
original.get_entity(reference)
);
assert!(app.tabs[i].scene.document.get_entity(member).is_none());
app.redo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(member),
changed.get_entity(member)
);
assert_eq!(
app.tabs[i].scene.document.get_entity(reference),
changed.get_entity(reference)
);
let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap();
app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes))
.read()
.unwrap();
app.refresh_properties();
let grip = app.tabs[i]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap();
assert!(grip.world.distance(glam::DVec3::new(20., 70., 0.)) < 1e-8);
let id = grip.id;
assert!(app.start_test_action_grip(reference, id));
feed(&mut app, "20,50");
let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(member).unwrap() else {
panic!()
};
assert!(line.start.x.abs() < 1e-8);
}
#[test]
fn authoring_edit_basepoint_attribute_relocation_undo_and_cancel() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER BASEPOINT 2,3");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let handle = crate::scene::block_basepoint::parameter(&app.tabs[i].scene.document, def)
.unwrap()
.0;
let mut attribute = codec::entities::AttributeEntity::new("TAG".into(), "value".into());
attribute.insertion_point = Vector3::new(7., 8., 0.);
let EntityType::Insert(insert) = app.tabs[i]
.scene
.document
.get_entity_mut(reference)
.unwrap()
else {
panic!()
};
insert.attributes.push(attribute);
let before = app.tabs[i].scene.document.clone();
let result = crate::command::CmdResult::EditBlockAuthoring {
document: app.tabs[i].id,
definition: def,
handle,
part: crate::scene::block_authoring::edit::Part::Point,
position: glam::DVec3::new(4., 6., 0.),
};
let _ = app.handle_authoring_edit(result);
assert_ne!(
app.tabs[i].scene.document.get_entity(reference),
before.get_entity(reference)
);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
app.redo_steps(1);
assert_ne!(
app.tabs[i].scene.document.get_entity(reference),
before.get_entity(reference)
);
app.tabs[i].selected_authoring = Some(handle);
app.refresh_properties();
let id = app.tabs[i].selected_grips[0].id;
assert!(app.start_authoring_grip(handle, id));
let after = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, after);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].selected_authoring, None);
}
#[test]
fn authoring_edit_mouse_selection_endpoint_label_marker_history_and_reopen() {
use crate::scene::block_authoring::edit::{self, Part};
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 2,2 6,2 4,3");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone();
let targets = members(&app);
let a = actions::create(
&mut app.tabs[i].scene.document,
def,
Kind::Move,
p.handle,
&targets,
glam::DVec3::new(5., 5., 0.),
)
.unwrap();
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.);
app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64(
glam::DVec3::new(-1., -1., 0.),
glam::DVec3::new(10., 7., 0.),
1600. / 900.,
);
let click = |app: &mut OpenCADStudio, point: Vector3| {
let scene = &app.tabs[app.active_tab].scene;
let (w, h) = scene.selection.borrow().view.vp_size;
let b = scene.active_model_tile_bounds(w, h);
let s = scene
.camera
.borrow()
.project(
glam::DVec3::new(point.x, point.y, point.z),
iced::Rectangle {
x: 0.,
y: 0.,
width: b.width,
height: b.height,
},
)
.unwrap();
let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new(
s.x + b.x,
s.y + b.y,
)));
let _ = app.update(Message::ViewportLeftRelease);
};
click(&mut app, Vector3::new(4., 3., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(p.handle));
assert_eq!(app.tabs[i].selected_grips.len(), 3);
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
click(&mut app, Vector3::new(6., 2., 0.));
assert_eq!(
app.tabs[i].active_cmd.as_ref().map(|c| c.name()),
Some("BEDITGRIP")
);
feed(&mut app, "8,2");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(
actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].end,
glam::DVec3::new(8., 2., 0.)
);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
app.redo_steps(1);
let label = edit::items(&app.tabs[i].scene.document, def)
.into_iter()
.find(|i| i.handle == p.handle)
.unwrap()
.grips
.into_iter()
.find(|(p, _)| *p == Part::Label(0))
.unwrap()
.1;
click(&mut app, label);
feed(&mut app, "5,4");
let graphic = edit::items(&app.tabs[i].scene.document, def)
.into_iter()
.find(|i| i.handle == p.handle)
.unwrap();
assert_eq!(graphic.graphic.labels[0].0, Vector3::new(5., 4., 0.));
let _ = app.update(Message::CommandEscape);
click(&mut app, Vector3::new(5., 5., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(a));
click(&mut app, Vector3::new(5., 5., 0.));
feed(&mut app, "7,5");
let action = actions::actions(&app.tabs[i].scene.document, def);
assert_eq!(action[0].1.display_location, Vector3::new(7., 5., 0.));
assert_eq!(action[0].1.dependencies, targets);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert!(app.tabs[i]
.selected_grips
.iter()
.all(|g| !super::block_authoring_edit::is_editor_grip(g.id)));
// Persisted native coordinates, rather than session-only display positions.
use std::io::Cursor;
let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap();
let loaded = codec::DwgReader::from_stream(Cursor::new(bytes))
.read()
.unwrap();
assert_eq!(
actions::parameters(&loaded, def, Kind::Move)[0].end,
glam::DVec3::new(8., 2., 0.)
);
assert_eq!(
actions::actions(&loaded, def)[0].1.display_location,
Vector3::new(7., 5., 0.)
);
app.tabs[i].scene.document = loaded;
begin(&mut app, reference);
click(&mut app, Vector3::new(8., 2., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(p.handle));
}
#[test]
fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() {
use crate::scene::block_actions::{self as actions, Kind};
for (kind, command) in [(Kind::Move, "BACTION MOVE"), (Kind::Scale, "BACTION SCALE")] {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let source = app.active_tab;
let _ = app.run_command_line("CIRCLE 0.5,0.5 0.2");
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 1,-0.2");
let definition = app.tabs[source]
.active_block_edit_session()
.unwrap()
.br_handle;
app.tabs[source].scene.selection.borrow_mut().view.vp_size = (1600., 900.);
app.tabs[source]
.scene
.camera
.borrow_mut()
.fit_to_bounds_f64(
glam::DVec3::new(-0.5, -0.5, 0.),
glam::DVec3::new(2.5, 1.5, 0.),
1600. / 900.,
);
let _ = app.dispatch_command(command);
let click = |app: &mut OpenCADStudio, point: [f64; 3], modifiers: &str| {
let scene = &app.tabs[app.active_tab].scene;
let (vw, vh) = scene.selection.borrow().view.vp_size;
let tile = scene.active_model_tile_bounds(vw, vh);
let screen = scene
.camera
.borrow()
.project(
glam::DVec3::from_array(point),
iced::Rectangle {
x: 0.,
y: 0.,
width: tile.width,
height: tile.height,
},
)
.unwrap();
let cursor = iced::Point::new(screen.x + tile.x, screen.y + tile.y);
let previous = (app.ctrl_down, app.shift_down);
app.ctrl_down = modifiers.contains("ctrl");
app.shift_down = modifiers.contains("shift");
let _ = app.update(Message::ViewportLeftPressAt(cursor));
let _ = app.update(Message::ViewportLeftRelease);
(app.ctrl_down, app.shift_down) = previous;
};
click(&mut app, [2., 0., 0.], "");
assert!(app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
feed(&mut app, ""); // Empty selection does not cancel or advance.
assert!(app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
for (point, modifiers, count) in [
([0.5, 0., 0.], "", 1),
([0.5, 0.7, 0.], "", 2),
([0.5, 0., 0.], "shift", 1),
([0.5, 0., 0.], "", 2),
] {
click(&mut app, point, modifiers);
assert!(
app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering(),
"every mouse pick/removal stays in object selection"
);
assert_eq!(app.tabs[source].scene.selected.len(), count);
assert!(actions::actions(&app.tabs[source].scene.document, definition).is_empty());
}
feed(&mut app, "");
assert!(!app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
click(&mut app, [1.5, 1., 0.], "");
assert!(app.tabs[source].active_cmd.is_none());
let authored = actions::actions(&app.tabs[source].scene.document, definition);
assert_eq!(authored.len(), 1);
assert_eq!(authored[0].2, kind);
assert_eq!(authored[0].1.dependencies.len(), 2);
let original = app.tabs[source].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.);
app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64(
glam::DVec3::new(-0.5, -0.5, 0.),
glam::DVec3::new(4.5, 1.5, 0.),
1600. / 900.,
);
click(&mut app, [0.5, 0., 0.], "");
click(&mut app, [2., 0., 0.], "");
assert_eq!(
app.tabs[i].active_cmd.as_ref().map(|c| c.name()),
Some("BTESTBLOCK")
);
click(&mut app, [4., 0., 0.], "");
assert!(app.tabs[i].active_cmd.is_none());
let circle = app.tabs[i]
.scene
.document
.entities_in_block("EDIT_ME")
.find_map(|e| {
if let EntityType::Circle(c) = e {
Some(c)
} else {
None
}
})
.unwrap();
let (center, radius) = if kind == Kind::Move {
(Vector3::new(2.5, 0.5, 0.), 0.2)
} else {
(Vector3::new(1., 1., 0.), 0.4)
};
// Mouse projection uses f32 screen coordinates; allow sub-pixel roundoff.
assert!(
(circle.center - center).length() < 1e-5,
"{kind:?}: actual {:?}, expected {center:?}",
circle.center
);
assert!((circle.radius - radius).abs() < 1e-5);
assert_eq!(app.tabs[source].scene.document, original);
}
}
#[test]
fn block_actions_command_selection_cancel_undo_and_test_block_isolation() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let source = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let definition = app.tabs[source]
.active_block_edit_session()
.unwrap()
.br_handle;
let p =
actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone();
let targets = members(&app);
let before = app.tabs[source].scene.document.clone();
let depth = app.tabs[source].history.undo_stack.len();
let _ = app.dispatch_command("BACTION MOVE");
feed(&mut app, &p.name);
assert!(app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(
targets.clone(),
));
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[source].scene.document, before);
assert_eq!(app.tabs[source].history.undo_stack.len(), depth);
for kind in ["Move", "Scale"] {
let _ = app.dispatch_command(&format!("BACTION {kind}"));
feed(&mut app, &p.name);
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(
targets.clone(),
));
assert!(app.tabs[source]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
let _ = app.feed_command(crate::command::StepInput::Enter);
feed(&mut app, "3,4");
assert!(app.tabs[source].active_cmd.is_none());
}
assert_eq!(
actions::actions(&app.tabs[source].scene.document, definition).len(),
2
);
assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 2);
app.undo_steps(1);
assert_eq!(
actions::actions(&app.tabs[source].scene.document, definition).len(),
1
);
app.redo_steps(1);
assert_eq!(
actions::actions(&app.tabs[source].scene.document, definition).len(),
2
);
let original = app.tabs[source].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
let test = app.active_tab;
let insert = app.tabs[test].block_test.as_ref().unwrap().insert;
app.tabs[test].selected_handle = Some(insert);
app.refresh_test_action_grips([0.; 3]);
let grip = app.tabs[test].selected_grips.last().unwrap().id;
assert!(app.start_test_action_grip(insert, grip));
feed(&mut app, "20,5");
let EntityType::Line(line) = app.tabs[test]
.scene
.document
.get_entity(targets[0])
.unwrap()
else {
panic!()
};
assert_eq!(line.start, Vector3::new(20., 0., 0.));
assert_eq!(line.end, Vector3::new(22., 0., 0.));
assert_eq!(app.tabs[source].scene.document, original);
app.undo_steps(1);
let EntityType::Line(line) = app.tabs[test]
.scene
.document
.get_entity(targets[0])
.unwrap()
else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
app.redo_steps(1);
let EntityType::Line(line) = app.tabs[test]
.scene
.document
.get_entity(targets[0])
.unwrap()
else {
panic!()
};
assert_eq!(line.start, Vector3::new(20., 0., 0.));
app.tabs[test]
.scene
.document
.add_entity(EntityType::Insert(Insert::new(
"EDIT_ME",
Vector3::new(50., 0., 0.),
)))
.unwrap();
let shared_before = app.tabs[test].scene.document.clone();
let id = app.tabs[test].id;
let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter {
insert: None,
document: id,
definition,
parameter: p.handle,
position: glam::DVec3::new(30., 0., 0.),
});
assert_eq!(
app.tabs[test].scene.document, shared_before,
"shared references are refused rather than silently modified together"
);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.active_tab, source);
assert_eq!(app.tabs[source].scene.document, original);
}
#[test]
fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_results_are_inert() {
use crate::scene::block_actions::Kind;
let (mut app, reference) = fixture();
let i = app.active_tab;
let _ = app.dispatch_command("LINE");
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BACTION MOVE");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.update(Message::CommandEscape);
begin(&mut app, reference);
let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = crate::scene::block_actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)
[0]
.handle;
let before = app.tabs[i].scene.document.clone();
let _ = app.handle_block_action_result(crate::command::CmdResult::PlaceBlockAction {
document: app.tabs[i].id + 1,
definition: def,
kind: Kind::Move,
parameter: p,
targets: members(&app),
location: glam::DVec3::ZERO,
});
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BACTION SCALE");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
#[test]
fn block_actions_test_reference_transform_visibility_and_noop_history() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let source = app.active_tab;
let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5");
let _ = app.run_command_line("BPARAMETER VISIBILITY 8,8");
let _ = app.run_command_line("BPARAMETER BASEPOINT 1,2");
let definition = app.tabs[source]
.active_block_edit_session()
.unwrap()
.br_handle;
let p =
actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone();
let targets = members(&app);
let _ = app.dispatch_command("BACTION MOVE");
feed(&mut app, &p.name);
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets));
let _ = app.feed_command(crate::command::StepInput::Enter);
feed(&mut app, "4,6");
let _ = app.dispatch_command("BTESTBLOCK");
let i = app.active_tab;
let insert = app.tabs[i].block_test.as_ref().unwrap().insert;
let depth = app.tabs[i].history.undo_stack.len();
let id = app.tabs[i].id;
let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else {
panic!()
};
let current_world = actions::v(
crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins)
.apply(actions::native(p.end)),
);
let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter {
insert: None,
document: id,
definition,
parameter: p.handle,
position: current_world,
});
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
// Existing visibility UI creates a per-reference anonymous definition.
app.apply_visibility_state(insert, 0);
app.finish_pending_history(i);
let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else {
panic!()
};
let after_world = actions::v(
crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins)
.apply(actions::native(p.end)),
);
assert_eq!(
after_world, current_world,
"visibility representation retains the native Basepoint anchor"
);
let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity_mut(insert).unwrap() else {
panic!()
};
ins.insert_point = Vector3::new(100., 200., 0.);
ins.rotation = std::f64::consts::FRAC_PI_2;
ins.set_x_scale(2.);
ins.set_y_scale(3.);
let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else {
panic!()
};
let name = ins.block_name.clone();
assert_ne!(name, "EDIT_ME");
let world = actions::v(
crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins)
.apply(Vector3::new(12., 13., 0.)),
);
let before = app.tabs[i].scene.document.clone();
let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter {
insert: None,
document: id,
definition,
parameter: p.handle,
position: world,
});
let anon = app.tabs[i]
.scene
.document
.entities_in_block(&name)
.find_map(|e| {
if let EntityType::Line(l) = e {
Some(l)
} else {
None
}
})
.unwrap();
assert!((anon.start.x - 10.).abs() < 1e-9 && (anon.start.y - 10.).abs() < 1e-9);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
}
fn authored_points(app: &OpenCADStudio) -> Vec<codec::objects::BlockPointParameter> {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_point_parameter::parameters(&tab.scene.document, definition)
.into_iter()
.map(|(_, parameter)| parameter.clone())
.collect()
}
fn distance_parameter_count(app: &OpenCADStudio) -> usize {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_distance_parameter::parameters(&tab.scene.document, definition).len()
}
fn orientation_parameter_count(app: &OpenCADStudio) -> usize {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_orientation_parameter::parameters(&tab.scene.document, definition).len()
}
#[test]
fn lookup_basepoint_placement_history_test_block_and_saved_parent_undo() {
use crate::scene::{block_basepoint, block_lookup, render_graph};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let members_before = members(&app);
let _ = app.run_command_line("BPARAMETER BASEPOINT 3,4");
let (handle, p) = block_basepoint::parameter(&app.tabs[i].scene.document, definition).unwrap();
assert_eq!(p.point, Vector3::new(3.0, 4.0, 0.0));
let depth = app.tabs[i].history.undo_stack.len();
assert_eq!(depth, 1);
let _ = app.run_command_line("BPARAMETER BASEPOINT 3,4");
assert_eq!(
app.tabs[i].history.undo_stack.len(),
depth,
"same anchor is a no-op"
);
let _ = app.run_command_line("BPARAMETER BASEPOINT 5,6");
assert_eq!(
block_basepoint::parameter(&app.tabs[i].scene.document, definition)
.unwrap()
.0,
handle
);
app.undo_steps(1);
assert_eq!(
block_basepoint::parameter(&app.tabs[i].scene.document, definition)
.unwrap()
.1
.point,
Vector3::new(3.0, 4.0, 0.0)
);
app.redo_steps(1);
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, "K");
feed(&mut app, "2,2");
assert_eq!(
block_lookup::parameters(&app.tabs[i].scene.document, definition).len(),
1
);
assert_eq!(
members(&app),
members_before,
"authoring objects are not drawing entities"
);
let before_test = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
let doc = &app.tabs[app.active_tab].scene.document;
let insert = doc
.entities()
.find_map(|e| match e {
EntityType::Insert(i) => Some(i),
_ => None,
})
.unwrap();
assert!(
(render_graph::insert_transform(doc, insert).apply(Vector3::new(5.0, 6.0, 0.0))
- insert.insert_point)
.length()
< 1e-9
);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.tabs[i].scene.document, before_test);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_some());
app.undo_steps(1);
assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_none());
app.redo_steps(1);
assert!(block_basepoint::parameter(&app.tabs[i].scene.document, definition).is_some());
}
fn lookup_editor_fixture() -> (OpenCADStudio, Handle, Handle) {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 0,1");
let _ = app.run_command_line("BPARAMETER LOOKUP 1,1");
let i = app.active_tab;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let parameter =
crate::scene::block_lookup::parameters(&app.tabs[i].scene.document, definition)[0].0;
(app, definition, parameter)
}
fn edit_lookup(app: &mut OpenCADStudio) {
use crate::ui::window::block_lookup::Action as A;
let _ = app.update(Message::BlockLookup(A::Property(0)));
let _ = app.update(Message::BlockLookup(A::AddProperty));
let _ = app.update(Message::BlockLookup(A::AddRow));
let _ = app.update(Message::BlockLookup(A::Cell(0, 1, "Small".into())));
}
#[test]
fn lookup_editor_cancel_apply_one_undo_and_stale_guard() {
use crate::ui::window::block_lookup::Action as A;
use crate::scene::block_lookup;
let (mut app, definition, parameter) = lookup_editor_fixture();
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
for cancel in [Message::CloseModal, Message::CommandEscape] {
let _ = app.dispatch_command("BLOOKUPTABLE");
edit_lookup(&mut app);
assert_eq!(app.tabs[i].scene.document, before, "draft is isolated");
let _ = app.update(cancel);
assert!(app.block_lookup.is_none());
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
}
let _ = app.dispatch_command("BLOOKUPTABLE");
let _ = app.update(Message::BlockLookup(A::Apply));
assert_eq!(
app.tabs[i].history.undo_stack.len(),
depth,
"unchanged OK is a no-op"
);
let _ = app.dispatch_command("BLOOKUPTABLE");
edit_lookup(&mut app);
let _ = app.update(Message::BlockLookup(A::Audit));
assert!(app.block_lookup.as_ref().unwrap().error.is_none());
let _ = app.update(Message::BlockLookup(A::Apply));
assert!(app.block_lookup.is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
assert_eq!(
block_lookup::table(&app.tabs[i].scene.document, definition, parameter)
.unwrap()
.unwrap()
.1
.expressions,
vec!["2", "Small"]
);
let saved = app.tabs[i].scene.document.clone();
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
app.redo_steps(1);
assert_eq!(app.tabs[i].scene.document, saved);
let _ = app.dispatch_command("BLOOKUPTABLE Lookup1");
let _ = app.update(Message::BlockLookup(A::Cell(0, 1, "Changed".into())));
app.tabs[i].scene.bump_geometry();
let _ = app.update(Message::BlockLookup(A::Apply));
assert!(app
.block_lookup
.as_ref()
.unwrap()
.error
.as_ref()
.unwrap()
.contains("changed"));
assert_eq!(app.tabs[i].scene.document, saved);
let _ = app.update(Message::CloseModal);
}
#[test]
fn lookup_editor_multiple_tables_apply_atomically_and_parameter_cancel_is_clean() {
use crate::ui::window::block_lookup::Action as A;
let (mut app, definition, _) = lookup_editor_fixture();
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LOOKUP 3,3");
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
for kind in ["BASEPOINT", "LOOKUP"] {
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
}
let _ = app.dispatch_command("BLOOKUPTABLE");
edit_lookup(&mut app);
let _ = app.update(Message::BlockLookup(A::Select(1)));
edit_lookup(&mut app);
let _ = app.update(Message::BlockLookup(A::Cell(0, 0, "NaN".into())));
let _ = app.update(Message::BlockLookup(A::Apply));
assert!(app.block_lookup.as_ref().unwrap().error.is_some());
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.update(Message::BlockLookup(A::Cell(0, 0, "5".into())));
let _ = app.update(Message::BlockLookup(A::Apply));
assert!(app.block_lookup.is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
for (h, _) in crate::scene::block_lookup::parameters(&app.tabs[i].scene.document, definition) {
assert!(
crate::scene::block_lookup::table(&app.tabs[i].scene.document, definition, h)
.unwrap()
.is_some()
);
}
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.dispatch_command("BEDIT_DISCARD");
let _ = app.dispatch_command("LINE");
for cmd in ["BPARAMETER BASEPOINT", "BPARAMETER LOOKUP", "BLOOKUPTABLE"] {
let _ = app.dispatch_command(cmd);
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
}
#[test]
fn orientation_parameter_batch_numeric_rotation_local_history_save_and_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
for (index, (kind, inputs)) in [
("ROTATION", vec!["0,0", "2", "90"]),
("ALIGNMENT", vec!["0,0", "0,2"]),
("FLIP", vec!["0,0", "0,2", "3,3"]),
]
.into_iter()
.enumerate()
{
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
for text in &inputs[..inputs.len() - 1] {
feed(&mut app, text);
assert_eq!(app.tabs[i].scene.document, before, "incomplete placement");
}
feed(&mut app, inputs.last().unwrap());
assert_eq!(orientation_parameter_count(&app), index + 1, "{kind}");
assert_eq!(app.tabs[i].history.undo_stack.len(), index + 1);
assert!(app.tabs[i].scene.preview_wires.is_empty());
app.undo_steps(1);
assert_eq!(orientation_parameter_count(&app), index);
app.redo_steps(1);
assert_eq!(orientation_parameter_count(&app), index + 1);
}
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = &crate::scene::block_orientation_parameter::parameters(
&app.tabs[i].scene.document,
definition,
)[0];
assert!((p.native.definition_end_point - Vector3::new(0.0, 2.0, 0.0)).length() < 1e-10);
assert_eq!(p.reference, Vector3::new(2.0, 0.0, 0.0));
let editor = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
assert!(app.tabs[app.active_tab]
.active_block_edit_session()
.is_none());
assert_eq!(
app.tabs[app.active_tab]
.scene
.document
.entities_in_block("EDIT_ME")
.count(),
1
);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.tabs[i].scene.document, editor);
let _ = app.run_command_line("UCS ORIGIN 1,2,0");
for p in crate::scene::block_orientation_parameter::parameters(
&app.tabs[i].scene.document,
definition,
) {
assert_eq!(
p.native.definition_base_point,
Vector3::new(-1.0, -2.0, 0.0)
);
}
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(orientation_parameter_count(&app), 3);
app.undo_steps(1);
assert_eq!(orientation_parameter_count(&app), 0);
app.redo_steps(1);
assert_eq!(orientation_parameter_count(&app), 3);
}
#[test]
fn orientation_parameter_chooser_cancels_invalid_picks_and_rotation_default_angle() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for (kind, maximum) in [("R", 2), ("A", 1), ("F", 2)] {
for picks in 0..=maximum {
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, kind);
if picks > 0 {
feed(&mut app, "0,0");
feed(&mut app, "0,0");
assert_eq!(app.tabs[i].scene.document, before);
}
if picks > 1 {
feed(&mut app, "0,2");
}
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
let _ = app.dispatch_command("BPARAMETER ROTATION");
feed(&mut app, "0,0");
feed(&mut app, "-2");
assert_eq!(app.tabs[i].scene.document, before);
feed(&mut app, "2");
feed(&mut app, "");
assert_eq!(orientation_parameter_count(&app), 1);
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(orientation_parameter_count(&app), 0);
let _ = app.dispatch_command("LINE");
for kind in ["ROTATION", "ALIGNMENT", "FLIP"] {
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
}
#[test]
fn orientation_parameter_stale_context_and_invalid_result_do_not_start_history() {
use crate::scene::block_orientation_parameter::Kind;
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let id = app.tabs[i].id;
let before = app.tabs[i].scene.document.clone();
for (document_id, end) in [
(id + 1, glam::DVec3::Y),
(id, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_orientation_parameter(
crate::command::CmdResult::PlaceBlockOrientationParameter {
document_id,
definition,
kind: Kind::Flip,
base: glam::DVec3::ZERO,
end,
reference: glam::DVec3::X,
},
);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
#[test]
fn distance_parameter_batch_three_picks_local_undo_redo_and_saved_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
for (index, kind) in ["LINEAR", "POLAR", "XY"].iter().enumerate() {
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
feed(&mut app, "2,3");
feed(&mut app, "6,7");
assert_eq!(
app.tabs[i].scene.document, before,
"base and endpoint are preview only"
);
feed(&mut app, "1,8");
assert_eq!(distance_parameter_count(&app), index + 1);
assert_eq!(app.tabs[i].history.undo_stack.len(), index + 1);
assert!(app.tabs[i].scene.preview_wires.is_empty());
app.undo_steps(1);
assert_eq!(distance_parameter_count(&app), index);
app.redo_steps(1);
assert_eq!(distance_parameter_count(&app), index + 1);
}
let _ = app.run_command_line("BPARAMETER POINT 0,0 1,1");
let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0");
let editor = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
assert!(app.tabs[app.active_tab]
.active_block_edit_session()
.is_none());
assert_eq!(
app.tabs[app.active_tab]
.scene
.document
.entities_in_block("EDIT_ME")
.count(),
1,
"parameters and their editor graphics add no drawing geometry"
);
assert_eq!(distance_parameter_count(&app), 3);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.active_tab, i);
assert_eq!(app.tabs[i].scene.document, editor);
let _ = app.run_command_line("UCS ORIGIN 1,2,0");
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
for p in
crate::scene::block_distance_parameter::parameters(&app.tabs[i].scene.document, definition)
{
assert_eq!(p.native.definition_base_point, Vector3::new(1.0, 1.0, 0.0));
assert_eq!(p.native.definition_end_point, Vector3::new(5.0, 5.0, 0.0));
}
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(distance_parameter_count(&app), 3);
app.undo_steps(1);
assert_eq!(distance_parameter_count(&app), 0);
app.redo_steps(1);
assert_eq!(distance_parameter_count(&app), 3);
}
#[test]
fn distance_parameter_cancel_chooser_invalid_endpoint_and_discard_are_safe() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for kind in ["Linear", "Polar", "XY"] {
for picks in 0..3 {
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, kind);
if picks > 0 {
feed(&mut app, "2,3");
}
if picks > 1 {
feed(&mut app, "6,7");
}
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
feed(&mut app, "2,3");
feed(&mut app, "2,3");
assert_eq!(app.tabs[i].scene.document, before);
assert!(
app.tabs[i].active_cmd.is_some(),
"invalid endpoint can be retried"
);
feed(&mut app, "6,7");
feed(&mut app, "1,8");
assert_eq!(distance_parameter_count(&app), 1);
app.undo_steps(1);
}
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,1 2,2");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(distance_parameter_count(&app), 0);
let _ = app.dispatch_command("LINE");
for kind in ["LINEAR", "POLAR", "XY"] {
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
}
#[test]
fn distance_parameter_stale_context_and_invalid_result_do_not_start_history() {
use crate::scene::block_distance_parameter::Kind;
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let id = app.tabs[i].id;
let before = app.tabs[i].scene.document.clone();
for (document_id, end) in [
(id + 1, glam::DVec3::ONE),
(id, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_distance_parameter(
crate::command::CmdResult::PlaceBlockDistanceParameter {
document_id,
definition,
kind: Kind::Linear,
base: glam::DVec3::ZERO,
end,
label: glam::DVec3::ZERO,
},
);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
#[test]
fn point_parameter_placement_two_picks_one_undo_and_repeated_names() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BPARAMETER POINT");
feed(&mut app, "3,4,0");
assert_eq!(
app.tabs[i].scene.document, before,
"first pick is preview only"
);
feed(&mut app, "6,7,0");
assert_eq!(authored_points(&app)[0].position_name, "Position1");
assert_eq!(
authored_points(&app)[0].definition_label_point,
Vector3::new(6.0, 7.0, 0.0)
);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
assert!(app.block_edit_dirty(i));
app.undo_steps(1);
assert!(authored_points(&app).is_empty());
app.redo_steps(1);
assert_eq!(authored_points(&app).len(), 1);
let _ = app.run_command_line("BPARAMETER POINT 8,9 10,11");
assert_eq!(authored_points(&app)[1].position_name, "Position2");
assert_eq!(app.tabs[i].history.undo_stack.len(), 2);
}
#[test]
fn point_parameter_placement_cancel_at_both_prompts_is_inert() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for picked in [false, true] {
let _ = app.dispatch_command("BPARAMETER POINT");
if picked {
feed(&mut app, "3,4");
}
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
assert!(!app.block_edit_dirty(i));
}
let _ = app.dispatch_command("BPARAMETER POINT");
feed(&mut app, "3,4");
feed(&mut app, "");
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
#[test]
fn point_parameter_placement_chooser_works_with_existing_visibility_and_reframes() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0");
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, "Point");
feed(&mut app, "3,4,5");
feed(&mut app, "6,7,5");
assert_eq!(authored_points(&app).len(), 1);
assert!(app.block_visibility_context().is_some());
let _ = app.run_command_line("UCS ORIGIN 1,2,3");
assert_eq!(
authored_points(&app)[0].parameter.definition_point,
Vector3::new(2.0, 2.0, 2.0)
);
assert_eq!(
authored_points(&app)[0].definition_label_point,
Vector3::new(5.0, 5.0, 2.0)
);
app.undo_steps(1);
assert_eq!(
authored_points(&app)[0].parameter.definition_point,
Vector3::new(3.0, 4.0, 5.0)
);
let _ = app.dispatch_command("BSAVE");
assert!(!app.block_edit_dirty(i));
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(authored_points(&app).len(), 1);
app.undo_steps(1);
assert!(
authored_points(&app).is_empty(),
"saved block undo restores the parent drawing"
);
}
#[test]
fn point_parameter_placement_discard_and_stale_context_leave_no_objects() {
let (mut app, reference) = fixture();
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
begin(&mut app, reference);
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let start = app.tabs[i].scene.document.clone();
let id = app.tabs[i].id;
for (document, location) in [
(id + 1, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_point_parameter(
crate::command::CmdResult::PlaceBlockPointParameter {
document_id: document,
definition,
location,
label: glam::DVec3::ZERO,
},
);
assert_eq!(app.tabs[i].scene.document, start);
}
let _ = app.run_command_line("BPARAMETER POINT 3,4 6,7");
let _ = app.dispatch_command("BEDIT_DISCARD");
let mut after = app.tabs[i].scene.document.clone();
after.vports = before.vports.clone();
assert_eq!(after, before);
let closed = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER POINT");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
assert_eq!(app.tabs[i].scene.document, closed);
}
fn visibility_editor() -> (OpenCADStudio, crate::ui::window::block_visibility::Target) {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0");
let target = app
.block_visibility_context()
.expect("placed parameter")
.target;
(app, target)
}
fn visibility_action(app: &mut OpenCADStudio, action: crate::ui::window::block_visibility::Action) {
let _ = app.update(Message::BlockVisibility(action));
}
#[test]
fn visibility_ribbon_manager_cancel_validation_and_single_undo() {
use crate::ui::window::block_visibility::Action as A;
let (mut app, target) = visibility_editor();
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
visibility_action(&mut app, A::Open(target));
visibility_action(&mut app, A::Name("Alternate".into()));
visibility_action(&mut app, A::New);
assert_eq!(app.tabs[i].scene.document, before, "dialog is staged");
let _ = app.update(Message::CommandEscape);
assert!(app.block_visibility.is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
assert_eq!(app.tabs[i].scene.document, before);
visibility_action(&mut app, A::Open(target));
visibility_action(&mut app, A::Name("visibilitystate0".into()));
visibility_action(&mut app, A::New);
assert!(app.block_visibility.as_ref().unwrap().error.is_some());
visibility_action(&mut app, A::Delete);
assert_eq!(
app.block_visibility.as_ref().unwrap().edited.states.len(),
1
);
visibility_action(&mut app, A::Name("Alternate".into()));
visibility_action(&mut app, A::New);
visibility_action(&mut app, A::SetCurrent);
visibility_action(&mut app, A::Name("Alternate renamed".into()));
visibility_action(&mut app, A::Rename);
visibility_action(&mut app, A::Move(true));
visibility_action(&mut app, A::Apply);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
assert_eq!(
app.block_visibility_context().unwrap().current.as_deref(),
Some("Alternate renamed")
);
app.undo_steps(1);
assert_eq!(
app.block_visibility_context().unwrap().names,
["VisibilityState0"]
);
app.redo_steps(1);
assert_eq!(
app.block_visibility_context().unwrap().names,
["Alternate renamed", "VisibilityState0"]
);
assert_eq!(
app.block_visibility_context().unwrap().current.as_deref(),
Some("Alternate renamed")
);
}
#[test]
fn visibility_ribbon_new_modes_state_selection_and_native_roundtrip() {
use crate::ui::window::block_visibility::{Action as A, NewVisibility as V};
let (mut app, target) = visibility_editor();
let i = app.active_tab;
let member = members(&app)[0];
visibility_action(&mut app, A::Open(target));
for (name, mode) in [("Hidden", V::Hide), ("Shown", V::Show), ("Copy", V::Keep)] {
visibility_action(&mut app, A::Name(name.into()));
visibility_action(&mut app, A::NewVisibility(mode));
visibility_action(&mut app, A::New);
}
visibility_action(&mut app, A::Apply);
let parameter =
crate::scene::block_visibility::parameter(&app.tabs[i].scene.document, target.definition)
.unwrap();
assert!(parameter.states[0].visible_blocks.contains(&member));
assert!(parameter.states[1].visible_blocks.is_empty());
assert!(parameter.states[2].visible_blocks.contains(&member));
assert!(parameter.states[3].visible_blocks.contains(&member));
visibility_action(&mut app, A::Current(target, "Hidden".into()));
assert!(
app.tabs[i]
.scene
.document
.get_entity(member)
.unwrap()
.common()
.invisible
);
visibility_action(&mut app, A::Current(target, "Shown".into()));
visibility_action(&mut app, A::Current(target, "Copy".into()));
app.undo_steps(1);
assert_eq!(
app.block_visibility_context().unwrap().current.as_deref(),
Some("Shown"),
"identical states retain their identity in history"
);
app.redo_steps(1);
assert_eq!(
app.block_visibility_context().unwrap().current.as_deref(),
Some("Copy")
);
app.save_block_edit(i);
for ext in ["dwg", "dxf"] {
let document = &app.tabs[i].scene.document;
let bytes = crate::io::save_to_bytes(document, ext, document.version).unwrap();
let loaded = crate::io::load_bytes(&format!("visibility.{ext}"), bytes).unwrap();
let parameter =
crate::scene::block_visibility::parameter(&loaded, target.definition).unwrap();
assert_eq!(
parameter
.states
.iter()
.map(|state| state.name.as_str())
.collect::<Vec<_>>(),
["VisibilityState0", "Hidden", "Shown", "Copy"]
);
assert!(parameter.states[1].visible_blocks.is_empty());
assert!(parameter.states[2].visible_blocks.contains(&member));
}
}
#[test]
fn visibility_ribbon_mode_and_make_visible_cancel_do_not_change_saved_flags() {
use crate::ui::window::block_visibility::Action as A;
let (mut app, target) = visibility_editor();
let i = app.active_tab;
let member = members(&app)[0];
app.tabs[i].scene.select_entity(member, true);
let _ = app.run_command_line("BVHIDE");
app.finish_pending_history(i);
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let dirty = app.tabs[i].dirty;
let wires = |app: &OpenCADStudio| {
app.tabs[i]
.scene
.entity_wires()
.iter()
.filter(|wire| crate::scene::Scene::handle_from_wire_name(&wire.name) == Some(member))
.cloned()
.collect::<Vec<_>>()
};
assert!(wires(&app).is_empty());
visibility_action(&mut app, A::Mode(target));
assert!(
!wires(&app).is_empty(),
"hidden member is rendered for picking"
);
assert_eq!(app.tabs[i].scene.document, before);
visibility_action(&mut app, A::Mode(target));
assert!(wires(&app).is_empty());
let _ = app.run_command_line("BVSHOW");
app.sync_block_visibility_display();
assert!(!wires(&app).is_empty());
let _ = app.update(Message::CommandEscape);
assert!(wires(&app).is_empty());
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
assert_eq!(app.tabs[i].dirty, dirty);
let _ = app.run_command_line("BVMODE 1");
assert!(app.block_visibility_mode);
let _ = app.run_command_line("SETVAR BVMODE 2");
assert!(app.block_visibility_mode);
let _ = app.run_command_line("BVMODE 0");
assert!(!app.block_visibility_mode);
assert!(
app.pending_setvar.is_none(),
"inline variable must close its probe prompt"
);
let _ = app.run_command_line("BVSHOW");
app.tabs[i].scene.select_entity(member, true);
let result = app.tabs[i]
.active_cmd
.as_mut()
.unwrap()
.on_selection_complete(vec![member]);
let _ = app.apply_cmd_result(result);
feed(&mut app, "");
assert!(
!app.tabs[i]
.scene
.document
.get_entity(member)
.unwrap()
.common()
.invisible
);
app.undo_steps(1);
assert!(
app.tabs[i]
.scene
.document
.get_entity(member)
.unwrap()
.common()
.invisible,
"undo uses the hidden flags from before picking"
);
}
#[test]
fn visibility_ribbon_manager_rejects_stale_target_and_stale_document() {
use crate::ui::window::block_visibility::Action as A;
let (mut app, target) = visibility_editor();
visibility_action(&mut app, A::Open(target));
visibility_action(&mut app, A::Name("Alternate".into()));
visibility_action(&mut app, A::New);
app.tabs[app.active_tab].scene.bump_geometry();
visibility_action(&mut app, A::Apply);
assert!(app.block_visibility.as_ref().unwrap().error.is_some());
assert_eq!(
app.block_visibility_context().unwrap().names,
["VisibilityState0"]
);
let _ = app.update(Message::CloseModal);
let mut stale = target;
stale.document += 1;
visibility_action(&mut app, A::Open(stale));
assert!(app.block_visibility.is_none());
assert!(app.active_modal.is_none());
let _ = app.run_command_line("BVSTATE DIALOG");
assert_eq!(app.active_modal, Some(ModalKind::BlockVisibility));
}
#[test]
fn local_undo_preserves_imported_native_networks_when_constraints_are_unchanged() {
use codec::objects::{
AssocNetwork, AssociativeData, AssociativeObject, Dictionary, ObjectType,
};
let (mut app, reference) = fixture();
let i = app.active_tab;
let doc = &mut app.tabs[i].scene.document;
let owner = doc.block_records.get("EDIT_ME").unwrap().handle;
let dictionary_handle = doc.allocate_handle();
let network_handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = owner;
dictionary.hard_owner = true;
dictionary.add_entry("ACAD_ASSOCNETWORK", network_handle);
doc.objects
.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
let network = AssociativeObject {
handle: network_handle,
owner: dictionary_handle,
reactors: vec![dictionary_handle],
dxf_name: "ASSOCNETWORK".into(),
cpp_class_name: "AcDbAssocNetwork".into(),
data: AssociativeData::Network(AssocNetwork::default()),
..Default::default()
};
doc.objects
.insert(network_handle, ObjectType::Associative(network));
begin(&mut app, reference);
let original_objects = app.tabs[i].scene.document.objects.clone();
circle(&mut app, 1.0);
assert!(!app.save_block_edit_as(i, "COPY"));
assert_eq!(app.tabs[i].scene.document.objects, original_objects);
app.undo_steps(1);
assert_eq!(members(&app).len(), 1);
assert_eq!(app.tabs[i].scene.document.objects, original_objects);
app.redo_steps(1);
assert_eq!(members(&app).len(), 2);
assert_eq!(app.tabs[i].scene.document.objects, original_objects);
}
#[test]
fn save_as_forks_unsaved_edits_and_undoes_as_one_drawing_step() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
app.save_block_edit(app.active_tab);
let saved = members(&app);
circle(&mut app, 2.0);
let edited = members(&app).len();
assert!(app.save_block_edit_as(app.active_tab, " COPY "));
let i = app.active_tab;
assert_eq!(members(&app), saved);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().block_name,
"COPY"
);
assert_eq!(
app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.unwrap()
.entity_handles
.len(),
edited
);
assert_eq!(main_depth(&app), 2);
assert!(app.tabs[i].history.undo_stack.is_empty());
assert!(!app.block_edit_dirty(i));
app.undo_steps(1);
assert!(app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.is_some());
circle(&mut app, 3.0);
app.close_block_edit(i, true);
assert_eq!(
app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.unwrap()
.entity_handles
.len(),
edited
);
app.undo_steps(1);
assert!(app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.is_none());
assert_eq!(members(&app), saved);
app.redo_steps(1);
assert_eq!(
app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.unwrap()
.entity_handles
.len(),
edited
);
}
#[test]
fn save_as_name_refusals_do_not_change_the_session() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
let i = app.active_tab;
let document = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
for name in ["", "bad/name", "edit_me", "EDIT_ME"] {
assert!(!app.save_block_edit_as(i, name));
assert_eq!(app.tabs[i].scene.document, document);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
assert_eq!(main_depth(&app), 0);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().block_name,
"EDIT_ME"
);
}
assert_eq!(
app.command_line.last_error.as_deref(),
Some("block \"EDIT_ME\" already exists")
);
}
#[test]
fn save_as_dialog_cancel_and_enter_follow_the_current_session() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
let i = app.active_tab;
let document = app.tabs[i].scene.document.clone();
let _ = app.update(Message::DropdownSelectItem {
dropdown_id: "BLOCK_SAVE",
cmd: "BSAVEAS",
});
assert_eq!(app.active_modal, Some(ModalKind::BlockSaveAs));
let _ = app.update(Message::BlockSaveAsName("edit_me".into()));
assert!(app.block_save_as.as_ref().unwrap().error.is_some());
let _ = app.update(Message::CommandEscape);
assert!(app.block_save_as.is_none());
assert_eq!(app.tabs[i].scene.document, document);
let _ = app.dispatch_command("BSAVEAS");
let _ = app.update(Message::BlockSaveAsName("COPY".into()));
assert!(app.block_save_as.as_ref().unwrap().error.is_none());
let _ = app.update(Message::CommandFinalize);
assert!(app.active_modal.is_none());
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().block_name,
"COPY"
);
}
#[test]
fn save_as_preserves_base_point_description_units_and_nested_attributes() {
use codec::entities::AttributeEntity;
let (mut app, reference) = fixture();
let i = app.active_tab;
app.tabs[i]
.scene
.define_block_raw("CHILD", Vector3::ZERO, vec![]);
let owner = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
let mut nested = Insert::new("CHILD", Vector3::new(4.0, 5.0, 0.0));
let mut attribute = AttributeEntity::new("TAG".into(), "value".into());
attribute.common.handle = app.tabs[i].scene.document.allocate_handle();
nested.attributes.push(attribute);
nested.common.owner_handle = owner;
nested.seqend_handle = Some(app.tabs[i].scene.document.allocate_handle());
let nested = app.tabs[i]
.scene
.document
.add_entity(EntityType::Insert(nested))
.unwrap();
let base = Vector3::new(12.0, 34.0, 0.0);
let record = app.tabs[i]
.scene
.document
.block_records
.get_mut("EDIT_ME")
.unwrap();
record.base_point = base;
record.description = "A reusable definition".into();
record.units = 4;
let source_marker = record.block_entity_handle;
if let Some(EntityType::Block(marker)) =
app.tabs[i].scene.document.get_entity_mut(source_marker)
{
marker.base_point = base;
}
let source_insert = app.tabs[i]
.scene
.document
.get_entity(nested)
.unwrap()
.clone();
begin(&mut app, reference);
assert!(app.save_block_edit_as(i, "COPY"));
let doc = &app.tabs[i].scene.document;
let record = doc.block_records.get("COPY").unwrap();
assert_eq!(record.base_point, base);
assert_eq!(record.units, 4);
assert_eq!(record.description, "A reusable definition");
assert_eq!(doc.get_entity(nested).unwrap(), &source_insert);
let copied = doc
.entities_in_block("COPY")
.find_map(|e| match e {
EntityType::Insert(insert) => Some(insert),
_ => None,
})
.unwrap();
let EntityType::Insert(original) = &source_insert else {
unreachable!()
};
assert_eq!(copied.block_name, "CHILD");
assert_eq!(copied.insert_point, original.insert_point);
assert_ne!(
copied.attributes[0].common.handle,
original.attributes[0].common.handle
);
assert_eq!(
copied.attributes[0].common.owner_handle,
copied.common.handle
);
assert_ne!(copied.seqend_handle, original.seqend_handle);
assert_eq!(copied.attributes[0].value, original.attributes[0].value);
}
#[test]
fn save_as_clones_extension_dictionaries_and_draw_order_independently() {
use codec::objects::{
Dictionary, ObjectType, SortEntitiesTable, XRecord, XRecordEntry, XRecordValue,
};
let (mut app, reference) = fixture();
let i = app.active_tab;
let owner = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
let first = members(&app)[0];
let mut second = Line::from_points(Vector3::ZERO, Vector3::UNIT_Y);
second.common.owner_handle = owner;
let second = app.tabs[i]
.scene
.document
.add_entity(EntityType::Line(second))
.unwrap();
let root = app.tabs[i]
.scene
.document
.ensure_extension_dictionary(owner);
let mut record = XRecord::new();
record.handle = app.tabs[i].scene.document.allocate_handle();
record.owner = root;
record
.entries
.push(XRecordEntry::new(340, XRecordValue::Handle(first)));
let leaf = record.handle;
app.tabs[i]
.scene
.document
.objects
.insert(leaf, ObjectType::XRecord(record));
let mut order = SortEntitiesTable::for_block(owner);
order.handle = app.tabs[i].scene.document.allocate_handle();
order.owner_handle = root;
order.add_entry(first, Handle::new(0x200));
order.send_to_back(second);
let order_handle = order.handle;
app.tabs[i]
.scene
.document
.objects
.insert(order_handle, ObjectType::SortEntitiesTable(order));
let Some(ObjectType::Dictionary(dictionary)) =
app.tabs[i].scene.document.objects.get_mut(&root)
else {
unreachable!()
};
dictionary.add_entry("DATA", leaf);
dictionary.add_entry("ACAD_SORTENTS", order_handle);
let original: Dictionary = dictionary.clone();
begin(&mut app, reference);
assert!(app.save_block_edit_as(i, "COPY"));
let doc = &app.tabs[i].scene.document;
let owner = doc.block_records.get("COPY").unwrap().handle;
let copy_members = doc
.block_records
.get("COPY")
.unwrap()
.entity_handles
.clone();
let copied_root = doc.extension_dictionary_handle(owner).unwrap();
assert_ne!(root, copied_root);
assert_eq!(
doc.objects.get(&root),
Some(&ObjectType::Dictionary(original))
);
let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get(&copied_root) else {
unreachable!()
};
let Some(ObjectType::XRecord(record)) = doc.objects.get(&dictionary.get("DATA").unwrap())
else {
unreachable!()
};
assert_eq!(record.owner, copied_root);
assert_eq!(
record.entries[0].value,
XRecordValue::Handle(copy_members[0])
);
let Some(ObjectType::SortEntitiesTable(order)) =
doc.objects.get(&dictionary.get("ACAD_SORTENTS").unwrap())
else {
unreachable!()
};
assert_eq!(order.block_owner_handle, owner);
assert!(
order.effective_sort_handle(copy_members[1]) < order.effective_sort_handle(copy_members[0])
);
app.close_block_edit(i, true);
app.undo_steps(1);
assert!(!app.tabs[i]
.scene
.document
.objects
.contains_key(&copied_root));
app.redo_steps(1);
assert!(app.tabs[i]
.scene
.document
.objects
.contains_key(&copied_root));
}
#[test]
fn save_as_preserves_block_parameters_and_remaps_constraint_geometry() {
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope};
let (mut app, reference) = fixture();
let i = app.active_tab;
let owner = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
let member = members(&app)[0];
let set = app.tabs[i]
.scene
.parametric_constraint_set_mut(ParametricScope::Block(owner));
set.local_parameters.set("width", "7").unwrap();
set.add(
ConstraintKind::Horizontal,
vec![ParametricRef::whole(member)],
None,
);
begin(&mut app, reference);
app.tabs[i]
.scene
.parametric_constraint_set_mut(ParametricScope::Block(owner))
.local_parameters
.set("width", "9")
.unwrap();
assert!(app.save_block_edit_as(i, "COPY"));
let new_owner = app.tabs[i]
.scene
.document
.block_records
.get("COPY")
.unwrap()
.handle;
let copied = app.tabs[i]
.scene
.parametric_constraint_set(ParametricScope::Block(new_owner))
.unwrap();
assert_eq!(copied.local_parameters.resolve("width").unwrap(), 9.0);
assert_ne!(copied.constraints[0].refs[0].entity, member);
assert_eq!(
app.tabs[i]
.scene
.parametric_constraint_set(ParametricScope::Block(owner))
.unwrap()
.local_parameters
.resolve("width")
.unwrap(),
7.0
);
app.close_block_edit(i, true);
app.undo_steps(1);
assert!(app.tabs[i]
.scene
.parametric_constraint_set(ParametricScope::Block(new_owner))
.is_none());
app.redo_steps(1);
assert_eq!(
app.tabs[i]
.scene
.parametric_constraint_set(ParametricScope::Block(new_owner))
.unwrap()
.local_parameters
.resolve("width")
.unwrap(),
9.0
);
}
#[test]
fn save_as_refuses_unsupported_owned_graphs_without_losing_edits() {
use codec::objects::{DynamicBlockObject, ObjectType};
let (mut app, reference) = fixture();
let i = app.active_tab;
let mut object = DynamicBlockObject::new("BLOCKLINEARPARAMETER", "LinearParameter");
object.handle = app.tabs[i].scene.document.allocate_handle();
object.owner = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
app.tabs[i]
.scene
.document
.objects
.insert(object.handle, ObjectType::DynamicBlock(object));
begin(&mut app, reference);
circle(&mut app, 1.0);
let document = app.tabs[i].scene.document.clone();
assert!(!app.save_block_edit_as(i, "COPY"));
assert_eq!(app.tabs[i].scene.document, document);
assert_eq!(members(&app).len(), 2);
assert_eq!(main_depth(&app), 0);
assert!(app
.command_line
.last_error
.as_ref()
.unwrap()
.contains("native object relationships"));
}
#[test]
fn save_as_retains_new_shared_layers_and_nested_definitions() {
use codec::entities::{AttributeDefinition, AttributeEntity, MText};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.dispatch_command("LAYER NEW COPY_LAYER");
let _ = app.dispatch_command("CLAYER COPY_LAYER");
let mut definition = AttributeDefinition::new("TAG".into(), "Prompt".into(), "first".into());
definition.is_multiline = true;
definition.mtext_flag = codec::entities::attribute_definition::MTextFlag::MultiLine;
let mut text = MText::new();
text.value = "first\\Psecond".into();
definition.embedded_mtext = Some(Box::new(text));
app.tabs[i].scene.define_block_raw(
"NEW_CHILD",
Vector3::ZERO,
vec![EntityType::AttributeDefinition(definition)],
);
let source_definition = app.tabs[i]
.scene
.document
.block_records
.get("NEW_CHILD")
.unwrap()
.entity_handles[0];
let mut child = Insert::new("NEW_CHILD", Vector3::ZERO);
let mut attribute = AttributeEntity::new("TAG".into(), "first".into());
attribute.attdef_handle = source_definition;
child.attributes.push(attribute);
child.common.layer = "COPY_LAYER".into();
child.common.owner_handle = app.tabs[i].active_block_edit_session().unwrap().br_handle;
app.tabs[i]
.scene
.document
.add_entity(EntityType::Insert(child))
.unwrap();
assert!(app.save_block_edit_as(i, "COPY"));
assert_eq!(app.tabs[i].active_layer, "COPY_LAYER");
assert!(app.tabs[i]
.scene
.document
.layers
.get("COPY_LAYER")
.is_some());
assert!(app.tabs[i]
.scene
.document
.block_records
.get("NEW_CHILD")
.is_some());
assert_eq!(members(&app).len(), 1);
let doc = &app.tabs[i].scene.document;
let copied_definition = doc.block_records.get("NEW_CHILD").unwrap().entity_handles[0];
assert_ne!(copied_definition, source_definition);
let EntityType::AttributeDefinition(definition) = doc.get_entity(copied_definition).unwrap()
else {
unreachable!()
};
let text = definition.embedded_mtext.as_ref().unwrap();
assert_eq!(text.value, "first\\Psecond");
assert!(!text.common.handle.is_null());
let copied_attribute = doc
.entities_in_block("COPY")
.find_map(|entity| match entity {
EntityType::Insert(insert) => Some(&insert.attributes[0]),
_ => None,
})
.unwrap();
assert_eq!(copied_attribute.attdef_handle, copied_definition);
assert!(app.tabs[i].scene.document.entities_in_block("COPY").any(|entity| matches!(entity, EntityType::Insert(insert) if insert.block_name=="NEW_CHILD" && insert.common.layer=="COPY_LAYER")));
}
#[test]
fn save_as_can_create_an_empty_definition() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let original = members(&app);
app.push_undo_snapshot(i, "Erase");
app.tabs[i].scene.erase_entities(&original);
app.finish_pending_history(i);
assert!(app.save_block_edit_as(i, "EMPTY"));
assert_eq!(members(&app), original);
assert!(app.tabs[i]
.scene
.document
.block_records
.get("EMPTY")
.unwrap()
.entity_handles
.is_empty());
assert!(!app.block_edit_dirty(i));
}
#[test]
fn session_undo_preserves_membership_and_cannot_reach_drawing_history() {
let (mut app, reference) = fixture();
circle(&mut app, 50.0);
let depth = main_depth(&app);
begin(&mut app, reference);
assert_eq!(main_depth(&app), depth);
circle(&mut app, 1.0);
assert_eq!(members(&app).len(), 2);
app.undo_steps(1);
assert_eq!(members(&app).len(), 1);
assert!(!app.block_edit_dirty(app.active_tab));
app.undo_steps(100);
assert_eq!(main_depth(&app), depth);
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
app.redo_steps(1);
assert_eq!(members(&app).len(), 2);
assert!(members(&app).iter().all(|h| app.tabs[app.active_tab]
.scene
.document
.get_entity(*h)
.is_some()));
}
#[test]
fn repeated_saves_stay_open_and_commit_one_drawing_step_each() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
circle(&mut app, 2.0);
let _ = app.dispatch_command("BSAVE");
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
assert_eq!(main_depth(&app), 1);
assert!(!app.block_edit_dirty(app.active_tab));
let _ = app.dispatch_command("BSAVE");
assert_eq!(main_depth(&app), 1, "a clean save is not a new edit");
circle(&mut app, 3.0);
let _ = app.dispatch_command("BSAVE");
assert_eq!(main_depth(&app), 2);
circle(&mut app, 4.0);
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(members(&app).len(), 4, "discard restores the last save");
assert_eq!(main_depth(&app), 2);
assert_eq!(
app.tabs[app.active_tab]
.history
.undo_stack
.last()
.unwrap()
.label(),
"Edit block EDIT_ME"
);
app.undo_steps(1);
assert_eq!(members(&app).len(), 3);
app.undo_steps(1);
assert_eq!(members(&app).len(), 1);
app.redo_steps(2);
assert_eq!(members(&app).len(), 4);
}
#[test]
fn discard_preserves_drawing_redo_and_never_leaks_local_edits() {
let (mut app, reference) = fixture();
circle(&mut app, 50.0);
app.undo_steps(1);
let main_redo = app.tabs[app.active_tab].history.redo_stack.len();
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(members(&app).len(), 1);
assert_eq!(app.tabs[app.active_tab].history.redo_stack.len(), main_redo);
app.redo_steps(1);
assert_eq!(members(&app).len(), 1);
}
#[test]
fn dirty_close_cancel_discard_and_save_follow_the_checkpoint() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesCancel);
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
assert_eq!(members(&app).len(), 2);
let _ = app.dispatch_command("BCLOSE");
let _ = app.update(Message::BlockEditChangesDiscard);
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
assert_eq!(members(&app).len(), 1);
assert_eq!(main_depth(&app), 0);
begin(&mut app, reference);
circle(&mut app, 2.0);
let _ = app.dispatch_command("BCLOSE");
let _ = app.update(Message::BlockEditChangesSave);
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
assert_eq!(members(&app).len(), 2);
assert_eq!(main_depth(&app), 1);
}
#[test]
fn clean_close_does_not_prompt_or_dirty_the_drawing() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
assert!(!app.tabs[app.active_tab].dirty);
let _ = app.dispatch_command("BCLOSE");
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
assert!(app.active_modal.is_none());
assert!(!app.tabs[app.active_tab].dirty);
assert_eq!(main_depth(&app), 0);
}
#[test]
fn dirty_switch_can_cancel_and_never_nests_sessions() {
let (mut app, reference) = fixture();
let i = app.active_tab;
app.tabs[i]
.scene
.define_block_raw("OTHER", Vector3::ZERO, vec![]);
let other = app.tabs[i]
.scene
.document
.add_entity(EntityType::Insert(Insert::new("OTHER", Vector3::ZERO)))
.unwrap();
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", other.value()));
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesCancel);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().block_name,
"EDIT_ME"
);
let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", other.value()));
let _ = app.update(Message::BlockEditChangesSave);
assert_eq!(app.tabs[i].block_edits.len(), 1);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().block_name,
"OTHER"
);
assert_eq!(main_depth(&app), 1);
}
#[test]
fn tab_close_prompts_for_block_edits_before_drawing_changes() {
let (mut app, reference) = fixture();
let id = app.tabs[app.active_tab].id;
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.update(Message::TabClose(id));
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesCancel);
assert!(app.tabs.iter().any(|t| t.id == id));
let _ = app.update(Message::TabClose(id));
let _ = app.update(Message::BlockEditChangesDiscard);
assert!(!app.tabs.iter().any(|t| t.id == id));
}
#[test]
fn save_refuses_a_cycle_introduced_outside_the_insert_command() {
let (mut app, reference) = fixture();
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
"OTHER",
Vector3::ZERO,
vec![EntityType::Insert(Insert::new("EDIT_ME", Vector3::ZERO))],
);
begin(&mut app, reference);
app.tabs[i]
.scene
.add_entity(EntityType::Insert(Insert::new("OTHER", Vector3::ZERO)));
assert!(!app.save_block_edit(i));
assert_eq!(main_depth(&app), 0);
assert!(app.tabs[i].active_block_edit.is_some());
}
#[test]
fn drawing_history_retains_the_paper_layout_after_save_and_undo() {
let (mut app, reference) = fixture();
let i = app.active_tab;
app.tabs[i].scene.add_layout("Sheet").unwrap();
app.tabs[i].scene.set_current_layout("Sheet".into());
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.dispatch_command("BEDIT_SAVE");
assert_eq!(app.tabs[i].scene.current_layout, "Sheet");
assert!(!app.tabs[i].scene.document.header.show_model_space);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.current_layout, "Sheet");
assert!(!app.tabs[i].scene.document.header.show_model_space);
assert_eq!(
crate::io::saved_active_layout(&app.tabs[i].scene.document).as_deref(),
Some("Sheet")
);
assert_eq!(members(&app).len(), 1);
app.redo_steps(1);
assert!(!app.tabs[i].scene.document.header.show_model_space);
assert_eq!(members(&app).len(), 2);
}
#[test]
fn local_parameters_are_saved_and_discarded_with_the_definition() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let scope = app.tabs[i].current_parametric_scope();
app.tabs[i]
.scene
.parametric_constraint_set_mut(scope)
.local_parameters
.set("Width", "7")
.unwrap();
assert!(app.save_block_edit(i));
app.tabs[i]
.scene
.parametric_constraint_set_mut(scope)
.local_parameters
.set("Width", "9")
.unwrap();
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(
app.tabs[i]
.scene
.parametric_constraint_set(scope)
.unwrap()
.local_parameters
.resolve("Width")
.unwrap(),
7.0
);
app.undo_steps(1);
assert!(!app.tabs[i]
.scene
.parametric_constraint_set(scope)
.unwrap()
.local_parameters
.contains("Width"));
app.redo_steps(1);
assert_eq!(
app.tabs[i]
.scene
.parametric_constraint_set(scope)
.unwrap()
.local_parameters
.resolve("Width")
.unwrap(),
7.0
);
}
#[test]
fn quit_prompts_for_block_edits_before_unsaved_drawing_changes() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.request_quit_with_block_edits();
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesCancel);
assert!(app.pending_close.is_none());
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
let _ = app.request_quit_with_block_edits();
let _ = app.update(Message::BlockEditChangesSave);
assert!(app.tabs[app.active_tab].active_block_edit.is_none());
assert_eq!(app.active_modal, Some(ModalKind::Unsaved));
assert!(matches!(app.pending_close, Some(super::PendingClose::Quit)));
}
#[test]
fn document_replacement_is_refused_until_the_editor_is_closed() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], false);
assert!(app.tabs[app.active_tab].active_block_edit.is_some());
}
#[test]
fn erasing_the_last_member_saves_an_empty_definition_and_undo_restores_it() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let line = members(&app)[0];
app.push_undo_snapshot(i, "Erase block member");
app.tabs[i].scene.erase_entities(&[line]);
app.finish_pending_history(i);
assert!(members(&app).is_empty());
app.undo_steps(1);
assert_eq!(members(&app), vec![line]);
app.redo_steps(1);
assert!(members(&app).is_empty());
let _ = app.dispatch_command("BEDIT_SAVE");
assert!(members(&app).is_empty());
app.undo_steps(1);
assert_eq!(members(&app), vec![line]);
}
#[test]
fn close_all_resumes_after_discarding_a_dirty_block() {
let (mut app, reference) = fixture();
let first = app.tabs[app.active_tab].id;
app.tabs.push(super::document::DocumentTab::new_drawing(2));
let second = app.tabs.last().unwrap().id;
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.update(Message::DocTabCloseAll);
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesDiscard);
assert!(!app
.tabs
.iter()
.any(|tab| tab.id == first || tab.id == second));
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();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.define_block_raw(
"DOOR",
Vector3::ZERO,
vec![EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::UNIT_X,
))],
);
app.tabs[tab]
.scene
.document
.block_records
.get_mut("DOOR")
.unwrap()
.description = "Wooden door".to_string();
app.tabs[tab]
.scene
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
let picker = app.block_edit_picker.as_ref().expect("picker state set");
assert_eq!(picker.existing_names, vec!["DOOR", "WINDOW"]);
assert_eq!(picker.name, "DOOR");
assert_eq!(picker.description, "Wooden door");
assert!(picker.error.is_none());
}
#[test]
fn locked_bedit_dialog_select_existing_block() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab]
.scene
.define_block_raw("DOOR", Vector3::ZERO, vec![]);
app.tabs[tab]
.scene
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerSelect("WINDOW".to_string()));
let picker = app.block_edit_picker.as_ref().unwrap();
assert_eq!(picker.name, "WINDOW");
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"WINDOW"
);
}
#[test]
fn locked_bedit_dialog_name_validation() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("INVALID/NAME".to_string()));
assert!(app.block_edit_picker.as_ref().unwrap().error.is_some());
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
assert!(app.tabs[tab].active_block_edit.is_none());
let _ = app.update(Message::BlockEditPickerName("".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
assert!(app.tabs[tab].active_block_edit.is_none());
let _ = app.update(Message::CloseModal);
assert_eq!(app.active_modal, None);
}
#[test]
fn locked_bedit_dialog_create_new_block_closes_without_saving() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW".to_string()));
assert!(app.block_edit_picker.as_ref().unwrap().error.is_none());
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"BRAND_NEW"
);
assert!(!app.block_edit_dirty(tab));
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("BRAND_NEW")
.is_none(),
"unpersisted new block closed without saving must not remain in drawing"
);
}
#[test]
fn locked_bedit_dialog_create_new_block_discard_dirty_leaves_no_block() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW2".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert!(app.tabs[tab].active_block_edit.is_some());
circle(&mut app, 1.0);
assert!(app.block_edit_dirty(tab));
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesDiscard);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("BRAND_NEW2")
.is_none(),
"discarded new block must not remain in drawing"
);
}
#[test]
fn locked_bedit_dialog_create_new_block_save_persists_and_undoes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("SAVED_BLOCK".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert!(app.tabs[tab].active_block_edit.is_some());
circle(&mut app, 1.0);
let _ = app.dispatch_command("BSAVE");
assert!(app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some());
let _ = app.dispatch_command("BCLOSE");
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some());
app.undo_steps(1);
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_none(),
"undo must revert block creation"
);
app.redo_steps(1);
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some(),
"redo must restore block creation"
);
}
#[test]
fn locked_bedit_with_preselected_insert_opens_directly() {
let (mut app, reference) = fixture();
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
app.tabs[tab].scene.select_entity(reference, true);
let _ = app.dispatch_command("BEDIT");
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"EDIT_ME"
);
}
#[test]
fn locked_bedit_clean_close_preserves_block_epoch_and_render_caches() {
let (mut app, reference) = fixture();
let tab = app.active_tab;
let epoch_before = app.tabs[tab].scene.block_epoch;
begin(&mut app, reference);
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.tabs[tab].scene.block_epoch, epoch_before);
}
#[test]
fn locked_bedit_discard_preserves_block_epoch_and_render_caches() {
let (mut app, reference) = fixture();
let tab = app.active_tab;
let epoch_before = app.tabs[tab].scene.block_epoch;
begin(&mut app, reference);
circle(&mut app, 1.0);
let _ = app.dispatch_command("BCLOSE");
let _ = app.update(Message::BlockEditChangesDiscard);
assert_eq!(app.tabs[tab].scene.block_epoch, epoch_before);
}
fn placed_visibility(app: &OpenCADStudio) -> &codec::objects::BlockVisibilityParameter {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_visibility::parameter(&tab.scene.document, definition)
.expect("visibility parameter")
}
#[test]
fn visibility_parameter_placement_can_cancel_without_document_or_history_changes() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let history = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, "Visibility");
assert_eq!(
app.tabs[i].active_cmd.as_ref().unwrap().name(),
"BPARAMETER"
);
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.apply_cmd_result(crate::command::CmdResult::Cancel);
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), history);
assert!(!app.block_edit_dirty(i));
}
#[test]
fn visibility_parameter_placement_has_local_undo_redo_save_and_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(placed_visibility(&app).states[0].name, "VisibilityState0");
assert_eq!(
members(&app),
before.block_records.get("EDIT_ME").unwrap().entity_handles
);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
assert!(app.block_edit_dirty(i));
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document.objects, before.objects);
assert!(!app.block_edit_dirty(i));
app.redo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
let _ = app.dispatch_command("BSAVE");
assert!(!app.block_edit_dirty(i));
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
app.undo_steps(1);
let definition = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
assert!(
crate::scene::block_visibility::parameter(&app.tabs[i].scene.document, definition)
.is_none()
);
}
#[test]
fn visibility_parameter_placement_discard_removes_the_unsaved_graph() {
let (mut app, reference) = fixture();
let before = app.tabs[app.active_tab].scene.document.clone();
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "7,8,0");
let _ = app.dispatch_command("BEDIT_DISCARD");
// Closing the editor synchronizes the restored camera to the active VPORT.
let mut after = app.tabs[app.active_tab].scene.document.clone();
after.vports = before.vports.clone();
assert_eq!(after, before);
}
#[test]
fn visibility_parameter_placement_rejects_duplicates_stale_context_and_nonfinite_locations() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let document_id = app.tabs[i].id;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let before = app.tabs[i].scene.document.clone();
for (document_id, location) in [
(document_id + 1, glam::DVec3::ZERO),
(document_id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_visibility_parameter(document_id, definition, location);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.dispatch_command("BTESTBLOCK");
let test = app.active_tab;
let before = app.tabs[test].scene.document.clone();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
assert_eq!(app.tabs[test].active_cmd.as_ref().unwrap().name(), "LINE");
let _ = app.handle_place_block_visibility_parameter(document_id, definition, glam::DVec3::ZERO);
assert_eq!(app.tabs[test].scene.document, before);
}
#[test]
fn visibility_parameter_placement_preserves_an_opaque_authoring_graph() {
use codec::objects::{DynamicBlockObject, DynamicBlockData, ObjectType};
let (mut app, reference) = fixture();
let i = app.active_tab;
let doc = &mut app.tabs[i].scene.document;
let definition = doc.block_records.get("EDIT_ME").unwrap().handle;
let dictionary = doc.ensure_extension_dictionary(definition);
let graph_handle = doc.allocate_handle();
let mut graph = DynamicBlockObject::new("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph");
graph.handle = graph_handle;
graph.owner = dictionary;
graph.data = DynamicBlockData::Unknown;
doc.objects
.insert(graph_handle, ObjectType::DynamicBlock(graph));
if let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get_mut(&dictionary) {
dictionary.add_entry("ACAD_ENHANCEDBLOCK", graph_handle);
}
begin(&mut app, reference);
let before = app.tabs[i].scene.document.clone();
for command in [
"BPARAMETER VISIBILITY",
"BVSTATE",
"_BVSTATE\u{1f}NEW\u{1f}Other",
] {
let _ = app.dispatch_command(command);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
assert!(crate::scene::block_visibility::create(
&mut app.tabs[i].scene.document,
definition,
Vector3::ZERO
)
.is_none());
assert_eq!(app.tabs[i].scene.document, before);
}
#[test]
fn visibility_parameter_placement_roundtrips_its_location_and_reference_grip_in_dwg_and_dxf() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
let doc = &app.tabs[app.active_tab].scene.document;
for extension in ["dwg", "dxf"] {
let bytes = crate::io::save_to_bytes(doc, extension, doc.version).unwrap();
let read = crate::io::load_bytes(&format!("visibility.{extension}"), bytes).unwrap();
let (definition, parameter) = read
.dynamic_visibility_for_insert(reference)
.expect(extension);
assert_eq!(
read.block_records.get("EDIT_ME").unwrap().handle,
definition
);
assert_eq!(parameter.def_point, Vector3::new(2.0, 2.0, 0.0));
assert_eq!(parameter.states[0].name, "VisibilityState0");
let grips = read
.objects
.values()
.filter_map(|object| match object {
codec::objects::ObjectType::DynamicBlock(object)
if object.owner == parameter.owner =>
{
match &object.data {
codec::objects::DynamicBlockData::VisibilityGrip(grip) => {
Some(grip.location)
}
_ => None,
}
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(grips, [Vector3::new(2.0, 2.0, 0.0)], "{extension}");
}
}
#[test]
fn visibility_parameter_placement_follows_the_editor_coordinate_frame() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#)["ok"],
true
);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#)["ok"],
true
);
// BEDIT bakes UCS changes into the definition and returns to local WCS.
assert!(app.tabs[app.active_tab].active_ucs.is_none());
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
}
#[test]
fn visibility_parameter_placement_reframes_an_empty_definition_and_undoes_it() {
let (mut app, reference) = fixture();
let i = app.active_tab;
let handles = app.tabs[i]
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.entity_handles
.clone();
for handle in handles {
app.tabs[i].scene.document.remove_entity(handle);
}
begin(&mut app, reference);
let _ = app.dispatch_command("BPARAMETER VISIBILITY");
feed(&mut app, "3,4,0");
let before = app.tabs[i].scene.document.objects.clone();
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"],
true
);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
app.undo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(3.0, 4.0, 0.0)
);
assert_eq!(app.tabs[i].scene.document.objects, before);
app.redo_steps(1);
assert_eq!(
placed_visibility(&app).def_point,
Vector3::new(2.0, 2.0, 0.0)
);
}