cadcraft/crates/engine/tests/refedit.rs

294 lines
12 KiB
Rust

//! REFEDIT, REFSET and REFCLOSE: editing a block reference in place (#813).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Handle, Insert, Space};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
fn h(v: &Value) -> String {
v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_string()
}
fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64) -> String {
let kind = EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(at.0, at.1, 0.0),
scale: Vec3::new(scale, scale, scale),
rotation: rot_deg.to_radians(),
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
});
s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex()
}
/// Block B = a line (0,0)-(10,0) and a circle at (20,0) r2; references A (at 100,0) and C
/// (at 0,50, scaled 2, rotated 90 degrees).
fn setup() -> (Session, String, String) {
let mut s = Session::new();
let l = h(&s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap());
let c = h(&s.execute("circle", &json!({ "center": [20, 0], "radius": 2 })).unwrap());
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l, c], "keep": "delete" })).unwrap();
let a = insert(&mut s, "B", (100.0, 0.0), 1.0, 0.0);
let c = insert(&mut s, "B", (0.0, 50.0), 2.0, 90.0);
(s, a, c)
}
fn block_lines(d: &Drawing, name: &str) -> Vec<(Vec3, Vec3)> {
d.block(name).unwrap().entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a, l.b)) } else { None }).collect()
}
fn handles(v: &Value) -> Vec<String> {
v["working"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect()
}
fn model_len(s: &Session) -> usize {
s.doc().unwrap().model.len()
}
#[test]
fn edit_and_save_updates_every_reference() {
let (mut s, a, c) = setup();
let before = model_len(&s);
let r = s.execute("refedit", &json!({ "handle": a })).unwrap();
let work = handles(&r);
assert_eq!(work.len(), 2);
// The reference is hidden, the block's objects stand where it showed them.
let d = s.doc().unwrap();
assert!(!d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible);
assert_eq!(model_len(&s), before + 2);
let line = work.iter().find_map(|w| match &d.entity(Handle::parse_hex(w).unwrap()).unwrap().kind {
EntityKind::Line(l) => Some((w.clone(), l.clone())),
_ => None,
});
let (lh, l) = line.unwrap();
assert!((l.a.x - 100.0).abs() < 1e-9 && (l.b.x - 110.0).abs() < 1e-9);
// Edit with normal commands: stretch it up by 5 and draw a new line.
s.execute("move", &json!({ "handles": [lh], "delta": [0, 5] })).unwrap();
s.execute("line", &json!({ "points": [[100, 20], [110, 20]] })).unwrap();
// The new line joined the working set.
let rs = s.execute("refset", &json!({})).unwrap();
assert_eq!(rs["working"].as_array().unwrap().len(), 3);
let out = s.execute("refclose", &json!({})).unwrap();
assert_eq!(out["objects"], 3);
let d = s.doc().unwrap();
assert!(d.refedit.is_none());
assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible);
assert_eq!(model_len(&s), before, "working copies are gone, the block holds the objects");
let lines = block_lines(d, "B");
assert_eq!(lines.len(), 2);
assert!(lines.iter().any(|(p, q)| (p.y - 5.0).abs() < 1e-9 && (q.x - 10.0).abs() < 1e-9));
assert!(lines.iter().any(|(p, q)| (p.y - 20.0).abs() < 1e-9 && (p.x - 0.0).abs() < 1e-9 && (q.x - 10.0).abs() < 1e-9));
// The second reference shows the change through its own transform.
let _ = c;
assert!(s.doc().unwrap().block("B").unwrap().entities.len() == 3);
}
#[test]
fn discard_leaves_everything_as_it_was() {
let (mut s, a, _) = setup();
let before = s.doc().unwrap().clone();
let r = s.execute("refedit", &json!({ "handle": a })).unwrap();
s.execute("erase", &json!({ "handles": handles(&r) })).unwrap();
s.execute("refclose", &json!({ "option": "discard" })).unwrap();
let d = s.doc().unwrap();
assert!(d.refedit.is_none());
assert_eq!(d.block("B").unwrap().entities.len(), 2);
assert_eq!(d.model.len(), before.model.len());
assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible);
}
#[test]
fn erasing_a_working_object_removes_it_from_the_block() {
let (mut s, a, _) = setup();
let r = s.execute("refedit", &json!({ "handle": a })).unwrap();
let w = handles(&r);
s.execute("erase", &json!({ "handles": [w[0]] })).unwrap();
s.execute("refclose", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 1);
}
#[test]
fn objects_outside_the_working_set_cannot_be_edited() {
let (mut s, a, c) = setup();
let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap());
s.execute("refedit", &json!({ "handle": a })).unwrap();
let before = s.doc().unwrap().clone();
let _ = s.execute("erase", &json!({ "handles": [other.clone(), c.clone()] }));
let _ = s.execute("move", &json!({ "handles": [other.clone()], "delta": [1, 1] }));
let d = s.doc().unwrap();
assert!(d.entity(Handle::parse_hex(&other).unwrap()).is_some());
assert!(d.entity(Handle::parse_hex(&c).unwrap()).is_some());
assert_eq!(d.model.len(), before.model.len());
// Starting another edit, or saving, is refused.
assert!(s.execute("refedit", &json!({ "handle": c })).is_err());
assert!(s.execute("save", &json!({ "path": "/nonexistent-dir/x.dxf" })).is_err());
}
#[test]
fn refset_adds_and_removes_objects() {
let (mut s, a, _) = setup();
let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap());
// Edit only the circle: the line is a faded copy.
let (circle_src, line_src) = {
let b = s.doc().unwrap().block("B").unwrap();
let c = b.entities.iter().find(|e| matches!(e.kind, EntityKind::Circle(_))).unwrap().handle;
let l = b.entities.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle;
(c.hex(), l.hex())
};
let r = s.execute("refedit", &json!({ "handle": a, "objects": [circle_src] })).unwrap();
assert_eq!(r["faded"], 1);
assert_eq!(handles(&r).len(), 1);
// Add the host line: it goes into the block at close. Remove the circle: it stays in the block.
let rs = s.execute("refset", &json!({ "add": [other], "remove": handles(&r) })).unwrap();
assert_eq!(rs["added"], 1);
assert_eq!(rs["removed"], 1);
s.execute("refclose", &json!({})).unwrap();
let d = s.doc().unwrap();
let b = d.block("B").unwrap();
assert_eq!(b.entities.len(), 3);
assert!(b.entities.get(Handle::parse_hex(&line_src).unwrap()).is_some());
assert!(d.entity(Handle::parse_hex(&other).unwrap()).is_none(), "moved into the block");
// 300,0 relative to the reference at 100,0
assert!(block_lines(d, "B").iter().any(|(p, q)| (p.x - 200.0).abs() < 1e-9 && (q.x - 210.0).abs() < 1e-9));
assert!(s.execute("refset", &json!({ "add": ["FFFF"] })).is_err(), "no edit active");
}
#[test]
fn a_block_that_would_contain_itself_is_refused_and_the_edit_survives() {
let (mut s, a, _) = setup();
s.execute("refedit", &json!({ "handle": a })).unwrap();
// A reference to B drawn while editing B.
let nested = insert(&mut s, "B", (500.0, 0.0), 1.0, 0.0);
let _ = nested;
// inserted through the doc API: add it to the working set by hand
s.execute("refset", &json!({ "add": [nested] })).unwrap();
assert!(s.execute("refclose", &json!({})).is_err());
assert!(s.doc().unwrap().refedit.is_some(), "the edit is still active");
assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 2);
s.execute("refclose", &json!({ "option": "discard" })).unwrap();
assert!(s.doc().unwrap().refedit.is_none());
}
#[test]
fn undo_takes_the_edit_back_with_its_state() {
let (mut s, a, _) = setup();
let n = model_len(&s);
s.execute("refedit", &json!({ "handle": a })).unwrap();
s.undo().unwrap();
let d = s.doc().unwrap();
assert!(d.refedit.is_none());
assert_eq!(d.model.len(), n);
assert!(d.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible);
s.redo().unwrap();
assert!(s.doc().unwrap().refedit.is_some());
}
#[test]
fn files_never_hold_half_an_edit() {
let (mut s, a, _) = setup();
s.execute("refedit", &json!({ "handle": a })).unwrap();
let d = s.doc().unwrap();
let committed = d.committed();
assert!(committed.refedit.is_none());
assert_eq!(committed.model.len(), 2);
assert!(committed.entity(Handle::parse_hex(&a).unwrap()).unwrap().common.visible);
let dxf = cadcraft_io::write_dxf(d);
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
assert_eq!(back.model.len(), 2);
}
#[test]
fn hostile_requests_never_panic() {
let (mut s, a, _) = setup();
for p in [
json!({}),
json!({ "handle": "ZZZ" }),
json!({ "handle": 1e300 }),
json!({ "name": "nope" }),
json!({ "handle": a, "objects": "x" }),
json!({ "handle": a, "objects": [] }),
json!({ "handle": a, "objects": ["FFFFFFFF"] }),
json!({ "handle": a, "objects": [null, {}] }),
] {
assert!(s.execute("refedit", &p).is_err(), "{p}");
}
for cmd in ["refset", "refclose"] {
assert!(s.execute(cmd, &json!({})).is_err());
}
// A reference with zero scale, a non-uniform one and a non-finite one can't be edited.
let z = insert(&mut s, "B", (0.0, 0.0), 0.0, 0.0);
assert!(s.execute("refedit", &json!({ "handle": z })).is_err());
let nan = insert(&mut s, "B", (f64::NAN, 0.0), 1.0, 0.0);
assert!(s.execute("refedit", &json!({ "handle": nan })).is_err());
s.execute("refedit", &json!({ "handle": a })).unwrap();
for p in [json!({ "add": "x" }), json!({ "remove": ["ZZ"] }), json!({ "add": [null] }), json!({ "handles": 3 }), json!({ "remove": ["FFFFFFF"] })]
{
assert!(s.execute("refset", &p).is_err(), "{p}");
}
assert!(s.execute("refclose", &json!({ "option": "maybe" })).is_err());
assert!(s.doc().unwrap().refedit.is_some());
}
#[test]
fn a_block_that_references_itself_in_a_file_does_not_hang() {
let (mut s, a, _) = setup();
// Hostile drawing: B contains a reference to itself.
let kind = EntityKind::Insert(Insert {
block: "B".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: vec![],
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
});
let d = s.doc_mut().unwrap();
let hh = d.new_handle();
let mut b = (**d.block("B").unwrap()).clone();
b.entities.push(cadcraft_engine::doc::Entity::new(hh, kind));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
s.execute("refedit", &json!({ "handle": a })).unwrap();
assert!(s.execute("refclose", &json!({})).is_err());
s.execute("refclose", &json!({ "option": "discard" })).unwrap();
}
#[test]
fn interactive_refedit_refset_and_refclose() {
use cadcraft_engine::Input;
let (mut s, a, _) = setup();
let other = h(&s.execute("line", &json!({ "points": [[300, 0], [310, 0]] })).unwrap());
s.start("refedit").unwrap();
s.input(Input::Pick(vec![Handle::parse_hex(&a).unwrap()])).unwrap();
assert!(s.running.is_none() && s.doc().unwrap().refedit.is_some());
s.start("refset").unwrap();
s.input(Input::Keyword("Add".into())).unwrap();
s.input(Input::Pick(vec![Handle::parse_hex(&other).unwrap()])).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(s.doc().unwrap().refedit.as_ref().unwrap().items.len(), 3);
s.start("refclose").unwrap();
s.input(Input::Keyword("Save".into())).unwrap();
assert!(s.doc().unwrap().refedit.is_none());
assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 3);
}
#[test]
fn xrefs_are_refused_with_a_message() {
let mut s = Session::new();
s.doc_mut().unwrap().blocks.insert(
"X".into(),
std::sync::Arc::new({
let mut b = cadcraft_engine::doc::Block::new("X");
b.xref_path = Some("x.dxf".into());
b
}),
);
let x = insert(&mut s, "X", (0.0, 0.0), 1.0, 0.0);
let e = s.execute("refedit", &json!({ "handle": x })).unwrap_err();
assert!(e.to_string().contains("external reference"), "{e}");
}