app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
//! Every fixture's artifact names must equal what the Python bridge's
//! `_library_artifact_names` produced for the same file (tests/golden/<name>.json).
use std::fs;
#[test]
fn artifact_names_match_the_python_bridge() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
let mut checked = 0;
for e in fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("lbrx") { continue; }
// syn_*.lbr are deliberately broken preflight fixtures
if p.file_name().unwrap().to_string_lossy().starts_with("syn_") { continue; }
let xml = fs::read_to_string(&p).unwrap();
let got = fusion_lbr::artifact_names(&xml).unwrap();
let golden_path = format!("{}/tests/golden/{}.json", env!("CARGO_MANIFEST_DIR"), p.file_stem().unwrap().to_str().unwrap());
let want: fusion_lbr::ArtifactNames = serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap();
assert_eq!(got, want, "artifact names differ from the Python golden for {}", p.display());
checked += 1;
}
assert!(checked >= 6, "expected the Demo Board merged library plus five parts, checked {checked}");
}
#[test]
fn a_non_library_is_refused_not_guessed() {
assert!(fusion_lbr::artifact_names("<html></html>").is_err());
assert!(fusion_lbr::artifact_names("not xml").is_err());
}
#[test]
fn binding_injection_matches_python_and_accumulates() {
let root = env!("CARGO_MANIFEST_DIR");
let src = fs::read_to_string(format!("{root}/tests/fixtures/Demo_Board.lbrx")).unwrap();
let plan: serde_json::Value = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/inject_bindings.json")).unwrap()).unwrap();
let parse = |k: &str| -> Vec<fusion_lbr::Binding> { serde_json::from_value(plan[k].clone()).unwrap() };
let first = fusion_lbr::inject_package3d_bindings(&src, &parse("first"));
assert_eq!(first, fs::read_to_string(format!("{root}/tests/golden/Demo_Board.bound3.lbrx")).unwrap(), "first injection differs from the Python golden");
let second = fusion_lbr::inject_package3d_bindings(&first, &parse("second"));
assert_eq!(second, fs::read_to_string(format!("{root}/tests/golden/Demo_Board.bound5.lbrx")).unwrap(), "subset re-injection must accumulate exactly as Python does");
// Python is not byte-idempotent (each re-run leaves one more blank line before <symbols>);
// the port reproduces that exactly rather than "fixing" the oracle.
let again = fusion_lbr::inject_package3d_bindings(&second, &parse("second"));
assert_eq!(again, fs::read_to_string(format!("{root}/tests/golden/Demo_Board.bound5again.lbrx")).unwrap(), "re-injection differs from the Python golden");
assert_eq!(again.replace('\n', ""), second.replace('\n', ""), "re-injection must be idempotent up to whitespace");
assert_eq!(fusion_lbr::artifact_names(&second).unwrap().p3d.len(), 5);
}
#[test]
fn metadata_matches_the_python_bridge() {
let root = env!("CARGO_MANIFEST_DIR");
let xml = fs::read_to_string(format!("{root}/tests/fixtures/Demo_Board.lbrx")).unwrap();
let got = fusion_lbr::library_part_metadata(&xml).unwrap();
let want: fusion_lbr::LibraryMetadata = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/Demo_Board.metadata.json")).unwrap()).unwrap();
assert_eq!(got, want);
}
#[test]
fn pairing_matches_the_python_bridge() {
let root = env!("CARGO_MANIFEST_DIR");
let cases: serde_json::Value = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/pair_parts.json")).unwrap()).unwrap();
for name in ["demo_board", "variants", "only_one_of_each"] {
let c = &cases[name];
let md: fusion_lbr::LibraryMetadata = serde_json::from_value(c["metadata"].clone()).unwrap();
let models: Vec<String> = serde_json::from_value(c["models"].clone()).unwrap();
let got = fusion_lbr::pair_parts(c["lbrPath"].as_str().unwrap(), &models, &md);
let want: fusion_lbr::Pairing = serde_json::from_value(c["expected"].clone()).unwrap();
assert_eq!(got, want, "pairing differs from the Python golden in case {name}");
}
for n in cases["names"]["ok"].as_array().unwrap() { assert!(fusion_lbr::library_name_ok(n.as_str().unwrap()), "{n} should be ok"); }
for n in cases["names"]["bad"].as_array().unwrap() { assert!(!fusion_lbr::library_name_ok(n.as_str().unwrap()), "{n} should be refused"); }
for (k, v) in cases["names"]["norm"].as_object().unwrap() { assert_eq!(fusion_lbr::norm(k), v.as_str().unwrap()); }
}
#[test]
fn split_per_part_matches_python_byte_for_byte() {
let root = env!("CARGO_MANIFEST_DIR");
let xml = fs::read_to_string(format!("{root}/tests/fixtures/Demo_Board.lbrx")).unwrap();
let out = std::env::temp_dir().join(format!("fusion-lbr-split-{}", std::process::id()));
let _ = fs::remove_dir_all(&out);
let got = fusion_lbr::split_per_part(&xml, &out).unwrap();
let index: serde_json::Value = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/split/index.json")).unwrap()).unwrap();
assert_eq!(got.len(), index.as_object().unwrap().len(), "entry count");
for (key, want) in index.as_object().unwrap() {
let e = got.get(key).unwrap_or_else(|| panic!("missing entry {key}"));
let file = want["file"].as_str().unwrap();
assert!(e.lbr_path.ends_with(file.trim_end_matches('x')), "{key}: path {} should end with {file}", e.lbr_path);
assert_eq!(e.package, want["package"].as_str().unwrap(), "{key}: package");
assert_eq!(serde_json::to_value(&e.symbols).unwrap(), want["symbols"], "{key}: symbols");
assert_eq!(serde_json::to_value(&e.packages).unwrap(), want["packages"], "{key}: packages");
let mine = fs::read_to_string(&e.lbr_path).unwrap();
let golden = fs::read_to_string(format!("{root}/tests/golden/split/{file}")).unwrap();
if mine != golden {
let (a, b): (Vec<&str>, Vec<&str>) = (mine.lines().collect(), golden.lines().collect());
let first = a.iter().zip(b.iter()).position(|(x, y)| x != y).unwrap_or(a.len().min(b.len()));
panic!("{file} differs from the Python golden at line {}:\n rust: {:?}\n python: {:?}", first + 1, a.get(first), b.get(first));
}
}
let _ = fs::remove_dir_all(&out);
}
#[test]
fn bundle_staging_matches_python() {
let root = env!("CARGO_MANIFEST_DIR");
let want: serde_json::Value = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/bundle_files.json")).unwrap()).unwrap();
let tmp = std::env::temp_dir().join(format!("fusion-lbr-bundle-{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
let n = fusion_lbr::safe_extract(std::path::Path::new(&format!("{root}/tests/fixtures/Demo_Board.bundle.zip")), &tmp).unwrap();
assert_eq!(n as u64, want["extracted"].as_u64().unwrap());
let found = fusion_lbr::find_bundle_files(&tmp);
let rel = |p: &str| p.trim_start_matches(&tmp.to_string_lossy().to_string()).trim_start_matches(std::path::MAIN_SEPARATOR).replace('\\', "/");
assert_eq!(rel(found.lbr.as_deref().unwrap()), want["lbr"].as_str().unwrap());
let models: Vec<String> = found.models.iter().map(|m| rel(m)).collect();
assert_eq!(serde_json::to_value(&models).unwrap(), want["models"]);
let dest = tmp.join("placed");
let (new_lbr, map) = fusion_lbr::place_library(std::path::Path::new(found.lbr.as_deref().unwrap()), &found.models, &dest).unwrap();
assert!(new_lbr.ends_with("/placed/Demo_Board.lbr"), "{new_lbr}");
assert_eq!(map.len(), found.models.len());
assert!(map.values().all(|v| std::path::Path::new(v).is_file()));
let slip = fusion_lbr::safe_extract(std::path::Path::new(&format!("{root}/tests/fixtures/zipslip.zip")), &tmp.join("slip"));
assert!(matches!(slip, Err(fusion_lbr::BundleError::UnsafeEntry(_))), "zip-slip must be refused: {slip:?}");
assert!(!tmp.join("evil.lbr").exists());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn preflight_and_lints_match_the_python_bridge() {
let root = env!("CARGO_MANIFEST_DIR");
let g: serde_json::Value = serde_json::from_str(&fs::read_to_string(format!("{root}/tests/golden/preflight_and_parts.json")).unwrap()).unwrap();
let mut checked = 0;
for (name, case) in g.as_object().unwrap() {
if name == "__missing__" { continue; }
let path = std::path::PathBuf::from(format!("{root}/tests/fixtures/{name}"));
let pf = fusion_lbr::preflight_lbr(&path);
let want = &case["preflight"];
assert_eq!(pf.ok, want["ok"].as_bool().unwrap(), "{name}: preflight ok");
let want_checks = want["checks"].as_array().unwrap();
assert_eq!(pf.checks.len(), want_checks.len(), "{name}: check count {:?}", pf.checks);
for (mine, theirs) in pf.checks.iter().zip(want_checks) {
assert_eq!(mine.check, theirs["check"].as_str().unwrap(), "{name}: check order");
assert_eq!(mine.ok, theirs["ok"].as_bool().unwrap(), "{name}: {} ok", mine.check);
// parser error strings legitimately differ between roxmltree and ElementTree
if mine.ok || mine.check != "xml-parses" { assert_eq!(mine.detail, theirs["detail"].as_str().unwrap(), "{name}: {} detail", mine.check); }
}
assert_eq!(pf.metadata_ok, want["metadataOk"].as_bool(), "{name}: metadataOk");
assert_eq!(pf.metadata_hint.as_deref(), want["_metadataHint"].as_str(), "{name}: metadata hint");
if let Some(mw) = &pf.metadata_warnings {
assert_eq!(serde_json::to_value(mw).unwrap(), want["metadataWarnings"], "{name}: preflight metadata warnings");
}
// the standalone lint
if let Ok(xml) = fs::read_to_string(&path) {
let lint = fusion_lbr::lint_part_metadata(&xml);
let want_l = &case["lint"];
assert_eq!(lint.ok, want_l["ok"].as_bool().unwrap(), "{name}: lint ok");
if want_l["error"].is_null() {
assert_eq!(serde_json::to_value(&lint.warnings).unwrap(), want_l["warnings"], "{name}: lint warnings");
assert_eq!(serde_json::to_value(&lint.totals).unwrap(), want_l["totals"], "{name}: lint totals");
}
}
checked += 1;
}
assert!(checked >= 10, "checked {checked}");
let missing = fusion_lbr::preflight_lbr(std::path::Path::new("C:/nope/missing.lbr"));
assert!(!missing.ok && missing.checks.len() == 1 && missing.checks[0].check == "file-readable");
}
#[test]
fn read_footprint_from_a_library_and_from_a_board() {
let lbr = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/Demo_Board.bound3.lbr")).unwrap();
let fp = fusion_lbr::read_footprint(&lbr, None, "QFN-48_7x7mm").unwrap();
let pads = fp["pads"].as_array().unwrap();
assert!(pads.len() >= 48, "{}", pads.len());
assert_eq!(fp["attributes"]["mount"], "smd");
assert_eq!(fp["models"][0]["name"], "QFN-48_7x7mm");
let c = fp["courtyardMm"].as_array().unwrap();
assert!(c[0].as_f64().unwrap() < c[2].as_f64().unwrap() && c[1].as_f64().unwrap() < c[3].as_f64().unwrap());
assert!(fusion_lbr::read_footprint(&lbr, None, "NOPE").is_err());
// a board embeds its libraries by name: Lib:Name picks the one the part comes from
let brd = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../demo/routing/fixture/fusion-routing.brd")).unwrap();
let doc = roxml_first_package(&brd);
let fp = fusion_lbr::read_footprint(&brd, Some(&doc.0), &doc.1).unwrap();
assert!(!fp["pads"].as_array().unwrap().is_empty());
assert!(fusion_lbr::read_footprint(&brd, Some("no-such-lib"), &doc.1).is_err());
}
fn roxml_first_package(xml: &str) -> (String, String) {
// the first <element library= package=> on the board
let i = xml.find("<element ").unwrap();
let tag = &xml[i..i + xml[i..].find('>').unwrap()];
let attr = |k: &str| { let j = tag.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; tag[j..j + tag[j..].find('"').unwrap()].to_string() };
(attr("library"), attr("package"))
}
#[test]
fn edit_footprint_pads_graphics_and_connects() {
let lbr = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/Demo_Board.bound3.lbr")).unwrap();
let edits = serde_json::json!({
"pads": [{"number": "1", "set": {"sizeMm": [0.3, 0.9], "newNumber": "A1"}}, {"number": "2", "delete": true}],
"addPads": [{"number": "EP2", "type": "smd", "at": [0, 0], "sizeMm": [1, 1]}],
"graphics": [{"layer": "F.SilkS", "mode": "append", "widthMm": 0.15, "shapes": [{"type": "line", "start": [0, 0], "end": [1, 1]}, {"type": "circle", "center": [0, 0], "radius": 0.2}]}],
"metadata": {"description": "edited"}});
let (text, summary) = fusion_lbr::edit_footprint(&lbr, "QFN-48_7x7mm", &edits).unwrap();
let fp = fusion_lbr::read_footprint(&text, None, "QFN-48_7x7mm").unwrap();
let pads = fp["pads"].as_array().unwrap();
let a1 = pads.iter().find(|p| p["number"] == "A1").unwrap();
assert_eq!(serde_json::json!([0.3, 0.9]), a1["sizeMm"]);
assert!(pads.iter().all(|p| p["number"] != "2" && p["number"] != "1"));
assert!(pads.iter().any(|p| p["number"] == "EP2"));
assert_eq!(fp["description"], "edited");
assert!(!summary["connectsDropped"].as_array().unwrap().is_empty());
// the device's connect for old pad 1 now names A1
assert!(text.contains("pad=\"A1\""));
assert!(fusion_lbr::edit_footprint(&lbr, "QFN-48_7x7mm", &serde_json::json!({"models": {"mode": "delete"}})).is_err());
assert!(fusion_lbr::edit_footprint(&lbr, "QFN-48_7x7mm", &serde_json::json!({"renumber": {"map": {"3": "4"}}})).is_err());
}
#[test]
fn authoring_builds_a_part_fusion_will_open() {
use fusion_lbr::authoring as a;
let pre = |t: &str| { let p = std::env::temp_dir().join(format!("adom-auth-{}.lbr", std::process::id())); std::fs::write(&p, t).unwrap(); let r = fusion_lbr::authoring::preflight_text(&p); let _ = std::fs::remove_file(&p); r };
// an SOIC-8 footprint in a new library (pads in mil, the standard's string form)
let pads = "1,-108,75,60,24,rect,0;2,-108,25,60,24,rect,0;3,-108,-25,60,24,rect,0;4,-108,-75,60,24,rect,0;5,108,-75,60,24,rect,0;6,108,-25,60,24,rect,0;7,108,25,60,24,rect,0;8,108,75,60,24,rect,0";
let (t, s) = a::create_footprint(None, &serde_json::json!({"name": "SOIC-8", "pads": pads})).unwrap();
assert_eq!(s["libraryState"], "created");
assert!(pre(&t).0, "{:?}", pre(&t).1);
// a dual op-amp: parts A and B, supply pins on a power gate
let pins = "1,OUTA,right,100,1;2,-INA,left,100,1;3,+INA,left,200,1;7,OUTB,right,100,2;6,-INB,left,100,2;5,+INB,left,200,2;8,V+,left,100,0;4,V-,left,200,0";
let (t, s) = a::create_symbol(Some(&t), &serde_json::json!({"name": "LM358", "pins": pins, "designator": "U"})).unwrap();
assert_eq!(s["symbols"][0]["parts"], 2);
assert!(pre(&t).0, "a symbol not yet on a footprint is fine mid-build: {:?}", pre(&t).1);
// join pins to pads and set parameters
let (t, s) = a::set_params(&t, Some("LM358"), &serde_json::json!({"MPN": "LM358DR", "Manufacturer": "TI"}), Some("LM358"), Some("SOIC-8")).unwrap();
assert_eq!(s["footprint"], "SOIC-8");
assert!(pre(&t).0, "{:?}", pre(&t).1);
assert!(fusion_lbr::preflight_lbr(&{ let p = std::env::temp_dir().join("adom-auth-full.lbr"); std::fs::write(&p, &t).unwrap(); p }).ok, "a complete part passes the full preflight");
let r = a::read_symbol(&t, "lm358").unwrap();
assert_eq!(r["pinsMode0"], 8);
assert_eq!(r["models"][0]["name"], "SOIC-8");
let outa = r["pins"].as_array().unwrap().iter().find(|p| p["name"] == "OUTA").unwrap();
assert_eq!(outa["des"], "1");
assert!(r["params"].as_array().unwrap().iter().any(|p| p["name"] == "MPN" && p["value"] == "LM358DR"));
// the library tools
let (t, _) = a::copy_footprint(&t, None, "SOIC-8", "SOIC-8-COPY").unwrap();
let (t, s) = a::rename_footprint(&t, "SOIC-8", "SOIC127P600X175-8").unwrap();
assert_eq!(s["devicesUpdated"], 1);
assert!(a::remove_footprint(&t, "SOIC127P600X175-8").is_err(), "a used footprint must be refused");
let (t, _) = a::remove_footprint(&t, "SOIC-8-COPY").unwrap();
let (t, _) = a::clone_symbol(&t, "LM358", "LM2904").unwrap();
let (t, _) = a::rename_symbol(&t, "LM2904", "LM2904B").unwrap();
let f = a::find_symbol(&[("lib".into(), t.clone())], "lm", false);
assert_eq!(f["matches"].as_array().unwrap().len(), 2);
let (t, s) = a::remove_symbol(&t, "LM2904B").unwrap();
assert!(!s["symbolsRemoved"].as_array().unwrap().is_empty());
assert!(a::read_symbol(&t, "LM358").is_ok());
// xcopy into a second new library
let (t2, _) = a::copy_footprint(&a::new_library("dest"), Some(&t), "SOIC127P600X175-8", "SO8").unwrap();
assert!(pre(&t2).0);
assert!(pre(&t).0, "{:?}", pre(&t).1);
}