Files
cadcraft/crates/engine/tests/xref.rs
T

405 lines
17 KiB
Rust

//! External references (#525): XATTACH and -XREF on real DXF files in a temporary folder —
//! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/
//! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::color::Color;
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, MAX_BLOCK_DEPTH, XrefStatus};
use serde_json::json;
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
}
/// A fresh folder for one test.
fn folder(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn save(d: &Drawing, path: &Path) {
std::fs::write(path, cadcraft_io::write(d, &path.to_string_lossy()).unwrap()).unwrap();
}
fn line(d: &mut Drawing, layer: &str, a: (f64, f64), b: (f64, f64)) {
let h = d.new_handle();
let mut e = Entity::new(h, EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
e.common.layer = layer.into();
d.model.push(e);
}
fn insert(block: &str, x: f64) -> EntityKind {
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(x, 0.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
})
}
/// A site plan: a red line on layer Walls and a reference to its block Tree (a circle).
fn site() -> Drawing {
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
line(&mut d, "Walls", (0.0, 0.0), (10.0, 0.0));
let mut tree = Block::new("Tree");
tree.entities.push(Entity::new(Handle(0x50), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 1.0 })));
d.blocks.insert("Tree".into(), Arc::new(tree));
let h = d.new_handle();
d.model.push(Entity::new(h, insert("Tree", 5.0)));
d
}
/// A drawing with an xref named after `path`'s stem (as attach or overlay) placed at 0,0.
fn host_with(xrefs: &[(&str, bool)]) -> Drawing {
let mut d = Drawing::new_metric();
for (path, overlay) in xrefs {
let name = Path::new(path).file_stem().unwrap().to_string_lossy().into_owned();
let mut b = Block::new(&name);
b.xref_path = Some(path.to_string());
b.xref_overlay = *overlay;
d.blocks.insert(name.clone(), Arc::new(b));
let h = d.new_handle();
d.model.push(Entity::new(h, insert(&name, 0.0)));
}
d
}
fn open(s: &mut Session, path: &Path) {
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
}
fn status(s: &Session, name: &str) -> Option<XrefStatus> {
s.doc().unwrap().block(name).map(|b| b.xref_status)
}
fn objects(s: &Session, name: &str) -> usize {
s.doc().unwrap().block(name).map_or(0, |b| b.entities.len())
}
fn has_layer(s: &Session, name: &str) -> bool {
s.doc().unwrap().layer(name).is_some()
}
#[test]
fn attach_loads_the_file_and_saving_keeps_only_the_reference() {
hooks();
let dir = folder("attach");
save(&site(), &dir.join("site.dxf"));
let host = dir.join("host.dxf");
save(&Drawing::new_metric(), &host);
let mut s = Session::new();
open(&mut s, &host);
// A path relative to the host drawing's folder, placed at 100,0.
let r = s.execute("xattach", &json!({ "path": "site.dxf", "at": [100, 0] })).unwrap();
assert_eq!(r["name"], "site");
assert_eq!(status(&s, "site"), Some(XrefStatus::Loaded));
assert_eq!(objects(&s, "site"), 2, "the site's model space");
let d = s.doc().unwrap();
assert_eq!(d.layer("site|Walls").map(|l| l.color), Some(Color::Index(1)), "layers come prefixed");
assert!(d.block("site|Tree").is_some(), "its blocks too");
let ext = d.extents(&cadcraft_engine::doc::Space::Model);
assert!(ext.max.x >= 110.0 - 1e-9, "the xref is drawn where it was placed: {ext:?}");
// Saved: the xref block with its path and no objects, and none of its symbols.
s.execute("qsave", &json!({})).unwrap();
let saved = cadcraft_io::read(&std::fs::read(&host).unwrap(), "host.dxf").unwrap();
let b = saved.block("site").unwrap();
assert_eq!((b.xref_path.as_deref(), b.entities.len()), (Some("site.dxf"), 0));
assert!(saved.layers.iter().all(|l| !l.name.contains('|')));
assert!(saved.blocks.keys().all(|n| !n.contains('|')));
// Reopened: loaded again from the file.
let mut t = Session::new();
open(&mut t, &host);
assert_eq!(status(&t, "site"), Some(XrefStatus::Loaded));
assert_eq!(objects(&t, "site"), 2);
assert!(has_layer(&t, "site|Walls"));
}
#[test]
fn reload_unload_path_and_detach() {
hooks();
let dir = folder("manage");
save(&site(), &dir.join("site.dxf"));
let host = dir.join("host.dxf");
save(&host_with(&[("site.dxf", false)]), &host);
let mut s = Session::new();
open(&mut s, &host);
assert_eq!(objects(&s, "site"), 2);
// A frozen xref layer stays frozen when the xref reloads.
s.doc_mut().unwrap().layer_mut("site|Walls").unwrap().frozen = true;
let mut changed = site();
line(&mut changed, "Walls", (0.0, 5.0), (10.0, 5.0));
save(&changed, &dir.join("site.dxf"));
s.execute("-xref", &json!({ "option": "reload", "names": "SITE" })).unwrap();
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
assert!(s.doc().unwrap().block("site|Tree").is_none());
s.execute("-xref", &json!({ "option": "reload" })).unwrap();
assert_eq!(status(&s, "site"), Some(XrefStatus::Loaded));
// Path: point it at another file.
std::fs::create_dir_all(dir.join("old")).unwrap();
save(&site(), &dir.join("old/site2.dxf"));
s.execute("-xref", &json!({ "option": "path", "names": "site", "path": "old/site2.dxf" })).unwrap();
assert_eq!(s.doc().unwrap().block("site").unwrap().xref_path.as_deref(), Some("old/site2.dxf"));
assert_eq!(objects(&s, "site"), 2);
// ?: the list the palette shows.
let r = s.execute("-xref", &json!({ "option": "?" })).unwrap();
assert_eq!(r["xrefs"][0]["status"], "Loaded");
assert_eq!(r["xrefs"][0]["references"], 1);
// Detach: the definition, its references and its symbols go.
s.execute("-xref", &json!({ "option": "detach", "names": "site" })).unwrap();
let d = s.doc().unwrap();
assert!(d.block("site").is_none());
assert!(d.model.iter().all(|e| !matches!(&e.kind, EntityKind::Insert(_))));
assert!(d.layers.iter().all(|l| !l.name.contains('|')));
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "site" })).is_err(), "nothing left to reload");
}
#[test]
fn missing_files_leave_the_host_intact() {
hooks();
let dir = folder("missing");
let host = dir.join("host.dxf");
save(&host_with(&[("gone.dxf", false)]), &host);
let mut s = Session::new();
open(&mut s, &host);
assert_eq!((status(&s, "gone"), objects(&s, "gone")), (Some(XrefStatus::NotFound), 0));
assert!(s.log.iter().any(|m| m.contains("gone") && m.contains("not loaded")), "{:?}", s.log);
// The reference survives a save.
s.execute("qsave", &json!({})).unwrap();
let saved = cadcraft_io::read(&std::fs::read(&host).unwrap(), "host.dxf").unwrap();
assert_eq!(saved.block("gone").and_then(|b| b.xref_path.clone()).as_deref(), Some("gone.dxf"));
// Attaching a file that isn't there changes nothing.
let before = s.doc().unwrap().blocks.len();
assert!(s.execute("xattach", &json!({ "path": "nowhere/x.dxf" })).is_err());
assert_eq!(s.doc().unwrap().blocks.len(), before);
// Found later by its file name next to the host, wherever it was saved from.
save(&site(), &dir.join("gone.dxf"));
s.execute("-xref", &json!({ "option": "path", "names": "gone", "path": "C:\\old\\job\\gone.dxf" })).unwrap();
assert_eq!(status(&s, "gone"), Some(XrefStatus::Loaded));
}
#[test]
fn overlays_do_not_load_in_the_drawings_that_reference_their_host() {
hooks();
let dir = folder("overlay");
save(&site(), &dir.join("site.dxf"));
let plan = dir.join("plan.dxf");
save(&host_with(&[("site.dxf", true)]), &plan);
let mut s = Session::new();
open(&mut s, &plan);
assert_eq!(status(&s, "site"), Some(XrefStatus::Loaded), "an overlay loads in its own host");
s.execute("qsave", &json!({})).unwrap();
let saved = cadcraft_io::read(&std::fs::read(&plan).unwrap(), "plan.dxf").unwrap();
assert!(saved.block("site").unwrap().xref_overlay, "the overlay flag is saved");
// Attached plan: its overlaid site isn't loaded; an attached one would be.
let top = dir.join("top.dxf");
save(&host_with(&[("plan.dxf", false)]), &top);
let mut t = Session::new();
open(&mut t, &top);
assert_eq!(status(&t, "plan"), Some(XrefStatus::Loaded));
assert_eq!(objects(&t, "plan|site"), 0);
s.execute("-xref", &json!({ "option": "type", "names": "site", "overlay": false })).unwrap();
s.execute("qsave", &json!({})).unwrap();
t.execute("-xref", &json!({ "option": "reload", "names": "plan" })).unwrap();
assert_eq!(objects(&t, "plan|site"), 2, "nested attached xrefs load");
assert!(t.doc().unwrap().layer("plan|site|Walls").is_some());
}
#[test]
fn circular_and_deeply_nested_references_are_bounded() {
hooks();
let dir = folder("cycle");
// a → b → a.
let mut a = host_with(&[("b.dxf", false)]);
line(&mut a, "0", (0.0, 0.0), (1.0, 1.0));
save(&a, &dir.join("a.dxf"));
let mut b = host_with(&[("a.dxf", false)]);
line(&mut b, "0", (0.0, 0.0), (2.0, 2.0));
save(&b, &dir.join("b.dxf"));
let mut s = Session::new();
open(&mut s, &dir.join("a.dxf"));
assert_eq!(status(&s, "b"), Some(XrefStatus::Loaded));
assert_eq!(objects(&s, "b|a"), 0, "the circular reference back to a isn't loaded");
// A drawing can't reference itself.
assert!(s.execute("xattach", &json!({ "path": "a.dxf", "name": "self" })).is_err());
// A chain deeper than the nesting limit stops at the limit.
let n = MAX_BLOCK_DEPTH + 4;
for i in 0..n {
let mut d = host_with(&[(&format!("f{}.dxf", i + 1), false)]);
line(&mut d, "0", (0.0, 0.0), (i as f64, 1.0));
save(&d, &dir.join(format!("f{i}.dxf")));
}
let mut t = Session::new();
open(&mut t, &dir.join("f0.dxf"));
assert_eq!(status(&t, "f1"), Some(XrefStatus::Loaded));
let depth = t.doc().unwrap().blocks.keys().map(|k| k.matches('|').count()).max().unwrap_or(0);
assert!(depth <= MAX_BLOCK_DEPTH, "nesting is bounded: {depth}");
}
#[test]
fn bind_makes_the_xref_part_of_the_drawing() {
hooks();
let dir = folder("bind");
save(&site(), &dir.join("site.dxf"));
let host = dir.join("host.dxf");
save(&host_with(&[("site.dxf", false)]), &host);
let mut s = Session::new();
open(&mut s, &host);
// XBIND copies one layer; the xref stays attached.
let r = s.execute("xbind", &json!({ "type": "layer", "names": "site|walls" })).unwrap();
assert_eq!(r["bound"][0], "site$0$Walls");
assert!(s.doc().unwrap().block("site").unwrap().xref_path.is_some());
// Bind: a normal block, its symbols renamed site$n$name.
s.execute("-xref", &json!({ "option": "bind", "names": "site" })).unwrap();
let d = s.doc().unwrap();
let b = d.block("site").unwrap();
assert!(b.xref_path.is_none());
assert_eq!(b.entities.len(), 2);
assert!(d.layer("site$1$Walls").is_some(), "the next free number after the XBIND copy");
assert!(d.block("site$0$Tree").is_some());
assert!(d.layers.iter().all(|l| !l.name.contains('|')));
assert!(b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "site$0$Tree")));
// It is saved as a normal block now.
s.execute("qsave", &json!({})).unwrap();
let saved = cadcraft_io::read(&std::fs::read(&host).unwrap(), "host.dxf").unwrap();
assert_eq!(saved.block("site").map(|b| b.entities.len()), Some(2));
assert!(saved.layer("site$1$Walls").is_some());
// Insert type: the symbols keep their own names (an existing one is used instead).
let mut t = Session::new();
let host2 = dir.join("host2.dxf");
let mut h2 = host_with(&[("site.dxf", false)]);
h2.layers.push(Layer { name: "Walls".into(), color: Color::Index(5), ..Layer::default() });
save(&h2, &host2);
open(&mut t, &host2);
t.execute("-xref", &json!({ "option": "bind", "insert": true })).unwrap();
let d = t.doc().unwrap();
assert_eq!(d.layer("Walls").map(|l| l.color), Some(Color::Index(5)));
assert!(d.block("Tree").is_some());
assert!(d.layers.iter().all(|l| !l.name.contains('|')));
// Unloaded xrefs can't be bound.
let mut u = Session::new();
open(&mut u, &host2);
u.execute("-xref", &json!({ "option": "unload" })).unwrap();
assert!(u.execute("-xref", &json!({ "option": "bind" })).is_err());
}
#[test]
fn command_line_xref_and_xattach() {
hooks();
let dir = folder("cmdline");
save(&site(), &dir.join("site.dxf"));
let host = dir.join("host.dxf");
save(&Drawing::new_metric(), &host);
let mut s = Session::new();
open(&mut s, &host);
s.script("-XREF\nA\nsite.dxf\n50,0\n2\n\n0\n").unwrap();
assert!(s.running.is_none(), "finished: {}", s.prompt_text());
let ins: Vec<(String, f64, f64)> = s
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Insert(i) => Some((i.block.clone(), i.insert.x, i.scale.y)),
_ => None,
})
.collect();
assert_eq!(ins, vec![("site".to_string(), 50.0, 2.0)]);
s.script("-XREF\n?\n\n").unwrap();
assert!(s.log.iter().any(|m| m.starts_with("site") && m.contains("Loaded")), "{:?}", s.log);
s.script("-XREF\nU\nsite\n").unwrap();
assert_eq!(status(&s, "site"), Some(XrefStatus::Unloaded));
s.script("-XREF\nR\n*\n").unwrap();
assert_eq!(status(&s, "site"), Some(XrefStatus::Loaded));
s.script("XBIND\nLA\nsite|Walls\n").unwrap();
assert!(has_layer(&s, "site$0$Walls"));
s.script("XATTACH\nsite.dxf\n0,0\n1\n\n0\n").unwrap();
assert!(s.running.is_none(), "finished: {}", s.prompt_text());
assert_eq!(s.doc().unwrap().blocks.values().filter(|b| b.xref_path.is_some()).count(), 1, "the same xref is used again");
s.script("-XREF\nD\nsite\n").unwrap();
assert!(s.doc().unwrap().block("site").is_none());
assert!(s.undo().unwrap().is_some());
assert!(s.doc().unwrap().block("site").is_some(), "detach undoes");
}
#[test]
fn hostile_xref_input_never_panics() {
hooks();
let dir = folder("hostile");
std::fs::write(dir.join("junk.dxf"), b"0\nSECTION\n2\nENTITIES\n0\nLINE\n10\nnan\n").unwrap();
std::fs::write(dir.join("empty.dxf"), b"").unwrap();
let host = dir.join("host.dxf");
let mut d = host_with(&[("junk.dxf", false), ("empty.dxf", false), ("../../../../../../dev/null", false)]);
let mut blank = Block::new("blank");
blank.xref_path = Some(String::new());
d.blocks.insert("blank".into(), Arc::new(blank));
save(&d, &host);
let mut s = Session::new();
open(&mut s, &host);
let dirpath = dir.to_string_lossy().into_owned();
for p in [
json!({}),
json!({ "path": "" }),
json!({ "path": dirpath }),
json!({ "path": "junk.dxf", "scale": 0 }),
json!({ "path": "junk.dxf", "scale": [1, "x"] }),
json!({ "path": "junk.dxf", "rotation": 1e308, "name": "a|b" }),
json!({ "path": "\u{0}" }),
json!({ "path": 5 }),
] {
let _ = s.execute("xattach", &p);
}
for p in [
json!({ "option": "nonsense" }),
json!({ "option": "path" }),
json!({ "option": "bind", "names": [1, 2] }),
json!({ "option": "detach", "names": "*" }),
json!({ "option": "reload", "names": "*" }),
] {
let _ = s.execute("-xref", &p);
}
for p in [json!({}), json!({ "type": "layer" }), json!({ "type": "block", "names": "|" }), json!({ "type": "x", "names": "*" })] {
let _ = s.execute("xbind", &p);
}
let _ = s.execute("xopen", &json!({ "name": "nothing" }));
let _ = s.execute("xopen", &json!({}));
}