Release v0.2.0: native GUI and managed KiCad libraries

This commit is contained in:
h3cx committed 2026-10-11 19:37:19 +02:00
1 parent 0ed28c03fb
commit c91f2ad67d
24 files changed
+9380 -527

No files matched your search

+818
View File
@@ -0,0 +1,818 @@
//! End-to-end CLI fixtures: all filesystem and process state is isolated.
use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::{Command, Output},
};
use tempfile::TempDir;
use zip::{ZipWriter, write::SimpleFileOptions};
#[path = "../src/sexpr.rs"]
#[allow(dead_code)]
mod sexpr;
use sexpr::{Node, parse};
struct Harness {
_tmp: TempDir,
root: PathBuf,
project: PathBuf,
config: PathBuf,
}
impl Harness {
fn new(policy: &str) -> Self {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_owned();
let project = root.join("project/board.kicad_pro");
fs::create_dir_all(project.parent().unwrap()).unwrap();
fs::write(&project, "{}").unwrap();
let config = root.join("config.toml");
let h = Self {
_tmp: temp,
root,
project,
config,
};
h.policy(policy);
h
}
fn policy(&self, policy: &str) {
fs::write(&self.config, format!("[libraries]\npath = {:?}\nkicad_config = {:?}\n[matching]\nambiguous = {policy:?}\n", self.root.join("global").to_str().unwrap(), self.root.join("kicad-config").to_str().unwrap())).unwrap();
}
fn cmd(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_uniloader"))
.args([
"--config",
self.config.to_str().unwrap(),
"--noninteractive",
])
.args(args)
.env("HOME", self.root.join("home"))
.env("XDG_CONFIG_HOME", self.root.join("xdg-config"))
.env("XDG_DATA_HOME", self.root.join("xdg-data"))
.env("XDG_CACHE_HOME", self.root.join("xdg-cache"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.current_dir(&self.root)
.output()
.unwrap()
}
fn import(&self, source: &Path, extra: &[&str]) -> Output {
let mut args = vec![
"import",
source.to_str().unwrap(),
"--project",
self.project.to_str().unwrap(),
];
args.extend_from_slice(extra);
self.cmd(&args)
}
fn bundle(&self, name: &str, files: &[(&str, String)]) -> PathBuf {
let path = self.root.join(name);
fs::create_dir_all(&path).unwrap();
for (file, text) in files {
let p = path.join(file);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, text).unwrap();
}
path
}
fn archive(&self, name: &str, files: &[(&str, String)]) -> PathBuf {
let path = self.root.join(name);
let mut zip = ZipWriter::new(fs::File::create(&path).unwrap());
for (file, text) in files {
zip.start_file(*file, SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
}
fn library(&self) -> Node {
parse(&fs::read_to_string(self.root.join("project/libraries/UniLoader.kicad_sym")).unwrap())
.unwrap()
}
fn footprints(&self) -> Vec<PathBuf> {
let dir = self.root.join("project/libraries/UniLoader.pretty");
if !dir.exists() {
return vec![];
}
let mut paths: Vec<_> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "kicad_mod"))
.collect();
paths.sort();
paths
}
}
fn ok(out: &Output) {
assert!(
out.status.success(),
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn output(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn footprint(name: &str, width: f64, extra: &str) -> String {
format!(
r#"(footprint "{name}" (version 20240108) (generator "fixture") (layer "F.Cu") (pad "1" smd rect (at 0 0) (size {width} 1) (layers "F.Cu" "F.Paste" "F.Mask")) {extra})"#
)
}
fn symbol(name: &str, fp: &str) -> String {
format!(
r#"(kicad_symbol_lib (version 20231120) (generator "fixture") (symbol "{name}" (property "Reference" "U") (property "Value" "{name}") (property "Footprint" "{fp}") (symbol "{name}_0_1" (text "µ Ω (quoted)" (at 0 0 0) (effects (font (size 1 1)))))))"#
)
}
fn symbols(n: &Node) -> Vec<&Node> {
n.children()
.iter()
.filter(|n| n.head() == Some("symbol"))
.collect()
}
fn link(n: &Node) -> &str {
n.children()
.iter()
.find(|n| n.head() == Some("property") && n.value() == Some("Footprint"))
.unwrap()
.children()[2]
.atom()
.unwrap()
}
#[test]
fn imports_three_provider_layouts_and_preserves_original_archives() {
for (archive, sym_path, fp_path, readme) in [
(
"LIB_123.zip",
"SamacSys_Parts/KiCad/Parts.kicad_sym",
"SamacSys_Parts/KiCad/Package.kicad_mod",
"SamacSys",
),
(
"ul_123.zip",
"KiCAD/component.kicad_sym",
"KiCAD/footprints.pretty/Package.kicad_mod",
"UltraLibrarian",
),
(
"snapmagic.zip",
"Component.kicad_sym",
"Component.pretty/Package.kicad_mod",
"SnapEDA",
),
] {
let h = Harness::new("ask");
let source = h.archive(
archive,
&[
(sym_path, symbol("Part", "OldLib:Package")),
(fp_path, footprint("Package", 1.0, "")),
("How-to-import.txt", readme.into()),
],
);
ok(&h.import(&source, &[]));
assert!(source.is_file());
assert_eq!(h.footprints().len(), 1);
let lib = h.library();
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package");
assert!(lib.to_string().contains("µ Ω (quoted)"));
assert_eq!(lib.field("version").unwrap().value(), Some("20231120"));
}
}
#[test]
fn exact_cross_provider_reuse_and_repeat_import_are_idempotent() {
let h = Harness::new("ask");
let a = h.archive(
"LIB_a.zip",
&[
("a.kicad_sym", symbol("First", "PkgA")),
("PkgA.kicad_mod", footprint("PkgA", 1.0, "(uuid a)")),
],
);
let b = h.archive(
"ul_b.zip",
&[
("b.kicad_sym", symbol("Second", "PkgB")),
("PkgB.kicad_mod", footprint("PkgB", 1.0, "(uuid b)")),
],
);
ok(&h.import(&a, &[]));
ok(&h.import(&b, &[]));
assert_eq!(h.footprints().len(), 1);
let lib = h.library();
assert_eq!(symbols(&lib).len(), 2);
assert!(symbols(&lib).iter().all(|s| link(s) == "UniLoader:PkgA"));
ok(&h.import(&a, &[]));
ok(&h.import(&b, &["--force"]));
assert_eq!(symbols(&h.library()).len(), 2);
assert_eq!(h.footprints().len(), 1);
}
#[test]
fn uncertain_footprints_stay_pending_then_retry_with_separate_policy() {
let h = Harness::new("ask");
let first = h.bundle(
"first",
&[
("a.kicad_sym", symbol("First", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let second = h.bundle(
"second",
&[
("b.kicad_sym", symbol("Second", "Package")),
("Package.kicad_mod", footprint("Package", 2.0, "")),
],
);
ok(&h.import(&first, &[]));
let result = h.import(&second, &[]);
assert!(output(&result).contains("pending"), "{}", output(&result));
assert_eq!(h.footprints().len(), 1);
assert_eq!(symbols(&h.library()).len(), 1);
let listing = h.cmd(&["pending"]);
ok(&listing);
assert!(output(&listing).contains("second"));
let records = h.root.join("xdg-data/uniloader/imports");
let pending: serde_json::Value = fs::read_dir(records)
.unwrap()
.map(|e| {
serde_json::from_slice::<serde_json::Value>(&fs::read(e.unwrap().path()).unwrap())
.unwrap()
})
.find(|v| v["source"].as_str().is_some_and(|s| s.ends_with("second")))
.unwrap();
h.policy("separate");
ok(&h.cmd(&["retry", pending["id"].as_str().unwrap()]));
assert_eq!(h.footprints().len(), 2);
let lib = h.library();
let second = symbols(&lib)
.into_iter()
.find(|s| s.value() == Some("Second"))
.unwrap();
assert!(link(second).starts_with("UniLoader:Package_"));
}
#[test]
fn dry_run_does_not_create_libraries_or_tables() {
let h = Harness::new("ask");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let result = h.import(&source, &["--dry-run"]);
ok(&result);
assert!(output(&result).contains("Would write"));
assert!(!h.root.join("project/libraries").exists());
assert!(!h.root.join("project/sym-lib-table").exists());
assert!(!h.root.join("project/fp-lib-table").exists());
}
#[test]
fn preserves_existing_tables_and_unknown_symbol_metadata() {
let h = Harness::new("ask");
fs::create_dir_all(h.root.join("other.pretty")).unwrap();
fs::write(h.root.join("project/fp-lib-table"),format!(r#"(fp_lib_table (version 7) (lib (name "Other") (type "KiCad") (uri "{}") (options "") (descr "Keep µ")))"#,h.root.join("other.pretty").display())).unwrap();
fs::write(h.root.join("project/sym-lib-table"),r#"(sym_lib_table (version 7) (lib (name "Other") (type "KiCad") (uri "${KIPRJMOD}/other.kicad_sym") (options "") (descr "Keep symbols")))"#).unwrap();
let mut sym = symbol("Part", "Package");
sym.insert_str(
sym.find("(symbol \"Part\"").unwrap(),
"(future_library (payload \"Ω (keep)\")) ",
);
let source = h.bundle(
"source",
&[
("a.kicad_sym", sym),
(
"Package.kicad_mod",
footprint("Package", 1.0, "(future_footprint (uuid meaningful))"),
),
],
);
ok(&h.import(&source, &[]));
assert!(h.library().field("future_library").is_some());
assert!(
fs::read_to_string(&h.footprints()[0])
.unwrap()
.contains("future_footprint")
);
for table in ["fp-lib-table", "sym-lib-table"] {
let table =
parse(&fs::read_to_string(h.root.join("project").join(table)).unwrap()).unwrap();
assert_eq!(
table
.children()
.iter()
.filter(|n| n.head() == Some("lib"))
.count(),
2
);
assert!(table.to_string().contains("Other"));
}
}
#[test]
fn repairs_broken_windows_models_and_reports_missing_dependencies() {
let h = Harness::new("ask");
let model = r#"(model "C:\\obsolete\\provider\\part.STEP" (offset (xyz 1 2 3)) (rotate (xyz 0 0 90)) (scale (xyz 1 1 1))) (model "missing.wrl")"#;
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, model)),
("deep/models/part.STEP", "STEP TEST CONTENT".into()),
],
);
let result = h.import(&source, &[]);
ok(&result);
assert!(output(&result).contains("Missing model"));
let fp = fs::read_to_string(&h.footprints()[0]).unwrap();
assert!(fp.contains("${KIPRJMOD}/libraries/models/part_"));
assert!(fp.contains("(xyz 1 2 3)"));
assert!(fp.contains("missing.wrl"));
let models: Vec<_> = fs::read_dir(h.root.join("project/libraries/models"))
.unwrap()
.collect();
assert_eq!(models.len(), 1);
}
#[test]
fn multi_symbol_footprint_bindings_do_not_use_first_footprint_for_all() {
let h = Harness::new("separate");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("First", "PackageA")),
("b.kicad_sym", symbol("Second", "PackageB")),
("PackageA.kicad_mod", footprint("PackageA", 1.0, "")),
("PackageB.kicad_mod", footprint("PackageB", 2.0, "")),
],
);
ok(&h.import(&source, &[]));
let lib = h.library();
for (name, expected) in [
("First", "UniLoader:PackageA"),
("Second", "UniLoader:PackageB"),
] {
let s = symbols(&lib)
.into_iter()
.find(|s| s.value() == Some(name))
.unwrap();
assert_eq!(link(s), expected);
}
}
#[test]
fn creates_and_registers_empty_global_destination() {
let h = Harness::new("ask");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
ok(&h.cmd(&["import", source.to_str().unwrap(), "--global"]));
for table in ["sym-lib-table", "fp-lib-table"] {
let text = fs::read_to_string(h.root.join("kicad-config").join(table)).unwrap();
assert!(text.contains("UniLoader"));
assert!(text.contains(h.root.join("global").to_str().unwrap()));
assert!(!text.contains("${KIPRJMOD}"));
}
assert!(!h.root.join("project/libraries").exists());
}
#[test]
fn same_plan_duplicate_footprints_with_models_reuse_one_copy() {
let h = Harness::new("ask");
let model = r#"(model "part.step" (offset (xyz 0 0 0)))"#;
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("First", "PackageA")),
("b.kicad_sym", symbol("Second", "PackageB")),
("PackageA.kicad_mod", footprint("PackageA", 1.0, model)),
("PackageB.kicad_mod", footprint("PackageB", 1.0, model)),
("part.step", "SAME STEP MODEL".into()),
],
);
ok(&h.import(&source, &[]));
assert_eq!(h.footprints().len(), 1);
let lib = h.library();
assert_eq!(symbols(&lib).len(), 2);
assert_eq!(link(symbols(&lib)[0]), link(symbols(&lib)[1]));
}
#[test]
fn symbol_conflict_renames_units_and_force_reimport_stays_idempotent() {
let h = Harness::new("separate");
let first = h.bundle(
"first",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let modified = symbol("Part", "Package").replace("µ Ω (quoted)", "different symbol geometry");
let second = h.bundle(
"second",
&[
("a.kicad_sym", modified),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
ok(&h.import(&first, &[]));
ok(&h.import(&second, &[]));
let lib = h.library();
assert_eq!(symbols(&lib).len(), 2);
let renamed = symbols(&lib)
.into_iter()
.find(|s| s.value() != Some("Part"))
.unwrap();
let renamed_name = renamed.value().unwrap();
assert!(renamed_name.starts_with("Part_"));
assert_eq!(
renamed.field("symbol").unwrap().value().unwrap(),
format!("{renamed_name}_0_1")
);
ok(&h.import(&second, &["--force"]));
assert_eq!(symbols(&h.library()).len(), 2);
}
#[test]
fn portable_project_copies_exact_global_footprint_and_model() {
let h = Harness::new("ask");
let model = r#"(model "part.step" (offset (xyz 0 0 0)))"#;
let global = h.bundle(
"global-source",
&[
("a.kicad_sym", symbol("GlobalPart", "GlobalPackage")),
(
"GlobalPackage.kicad_mod",
footprint("GlobalPackage", 1.0, model),
),
("part.step", "PORTABLE MODEL".into()),
],
);
ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"]));
let local = h.bundle(
"local-source",
&[
("a.kicad_sym", symbol("LocalPart", "LocalPackage")),
(
"LocalPackage.kicad_mod",
footprint("LocalPackage", 1.0, model),
),
("part.step", "PORTABLE MODEL".into()),
],
);
ok(&h.import(&local, &[]));
assert_eq!(h.footprints().len(), 1);
let text = fs::read_to_string(&h.footprints()[0]).unwrap();
assert!(text.contains("${KIPRJMOD}/libraries/models/"));
assert!(!text.contains(h.root.join("global").to_str().unwrap()));
let copied_models: Vec<_> = fs::read_dir(h.root.join("project/libraries/models"))
.unwrap()
.collect();
assert_eq!(copied_models.len(), 1);
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:GlobalPackage");
}
fn records(h: &Harness) -> Vec<serde_json::Value> {
fs::read_dir(h.root.join("xdg-data/uniloader/imports"))
.unwrap()
.map(|e| serde_json::from_slice(&fs::read(e.unwrap().path()).unwrap()).unwrap())
.collect()
}
fn append_config(h: &Harness, text: &str) {
let mut file = fs::OpenOptions::new().append(true).open(&h.config).unwrap();
writeln!(file, "{text}").unwrap();
}
#[test]
fn symbol_only_global_dependency_is_copied_into_portable_project() {
let h = Harness::new("ask");
let global = h.bundle(
"global-source",
&[
("a.kicad_sym", symbol("GlobalPart", "Package")),
(
"Package.kicad_mod",
footprint("Package", 1.0, r#"(model "part.step")"#),
),
("part.step", "GLOBAL MODEL".into()),
],
);
ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"]));
let source = h.bundle(
"symbol-only",
&[("b.kicad_sym", symbol("LocalPart", "UniLoader:Package"))],
);
ok(&h.import(&source, &[]));
assert_eq!(h.footprints().len(), 1);
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:Package");
assert!(
fs::read_to_string(&h.footprints()[0])
.unwrap()
.contains("${KIPRJMOD}/libraries/models/")
);
assert_eq!(
fs::read_dir(h.root.join("project/libraries/models"))
.unwrap()
.count(),
1
);
}
#[test]
fn same_archive_keeps_completed_history_for_each_destination() {
let h = Harness::new("ask");
let source = h.archive(
"LIB_multi.zip",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let second = h.root.join("second/second.kicad_pro");
fs::create_dir_all(second.parent().unwrap()).unwrap();
fs::write(&second, "{}").unwrap();
ok(&h.import(&source, &[]));
ok(&h.cmd(&[
"import",
source.to_str().unwrap(),
"--project",
second.to_str().unwrap(),
]));
let all = records(&h);
assert_eq!(all.len(), 2);
assert!(all.iter().all(|r| r["status"] == "completed"));
assert_ne!(all[0]["destination"], all[1]["destination"]);
let again = h.import(&source, &[]);
ok(&again);
assert!(output(&again).contains("Already imported"));
assert_eq!(records(&h).len(), 2);
}
#[test]
fn project_library_and_model_paths_resolve_after_directory_relocation() {
let h = Harness::new("ask");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
(
"Package.kicad_mod",
footprint("Package", 1.0, r#"(model "part.step")"#),
),
("part.step", "RELOCATED MODEL".into()),
],
);
ok(&h.import(&source, &[]));
let relocated = h.root.join("relocated project");
fs::rename(h.root.join("project"), &relocated).unwrap();
for file in ["sym-lib-table", "fp-lib-table"] {
let table = parse(&fs::read_to_string(relocated.join(file)).unwrap()).unwrap();
for lib in table.children().iter().filter(|n| n.head() == Some("lib")) {
let uri = lib.field("uri").unwrap().value().unwrap();
assert!(uri.starts_with("${KIPRJMOD}/"));
assert!(
relocated
.join(uri.strip_prefix("${KIPRJMOD}/").unwrap())
.exists()
);
}
}
let fp = parse(
&fs::read_to_string(relocated.join("libraries/UniLoader.pretty/Package.kicad_mod"))
.unwrap(),
)
.unwrap();
let uri = fp.field("model").unwrap().value().unwrap();
let path = relocated.join(uri.strip_prefix("${KIPRJMOD}/").unwrap());
assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL");
let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap())
.unwrap();
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package");
}
#[test]
fn disabled_footprint_import_reuses_registered_assets_and_rejects_dangling_links() {
let h = Harness::new("ask");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
ok(&h.import(&source, &[]));
append_config(&h, "[options]\nfootprints = false");
let valid = h.bundle(
"valid",
&[("b.kicad_sym", symbol("Second", "UniLoader:Package"))],
);
ok(&h.import(&valid, &[]));
let invalid = h.bundle(
"invalid",
&[("c.kicad_sym", symbol("Third", "Missing:NoSuchPackage"))],
);
let result = h.import(&invalid, &[]);
assert!(!result.status.success());
assert!(output(&result).contains("pending"));
assert_eq!(symbols(&h.library()).len(), 2);
assert_eq!(h.footprints().len(), 1);
}
#[test]
fn same_model_filename_with_different_content_gets_distinct_destinations() {
let h = Harness::new("separate");
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("First", "PackageA")),
("b.kicad_sym", symbol("Second", "PackageB")),
(
"PackageA.kicad_mod",
footprint("PackageA", 1.0, r#"(model "a/part.step")"#),
),
(
"PackageB.kicad_mod",
footprint("PackageB", 2.0, r#"(model "b/part.step")"#),
),
("a/part.step", "MODEL A".into()),
("b/part.step", "MODEL B".into()),
],
);
ok(&h.import(&source, &[]));
let mut model_contents: Vec<_> = fs::read_dir(h.root.join("project/libraries/models"))
.unwrap()
.map(|e| fs::read(e.unwrap().path()).unwrap())
.collect();
model_contents.sort();
assert_eq!(
model_contents,
vec![b"MODEL A".to_vec(), b"MODEL B".to_vec()]
);
let paths: Vec<_> = h
.footprints()
.iter()
.map(|p| {
parse(&fs::read_to_string(p).unwrap())
.unwrap()
.field("model")
.unwrap()
.value()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1]);
}
#[test]
fn retry_keeps_reviewed_project_when_default_destination_changes() {
let h = Harness::new("ask");
let first = h.bundle(
"first",
&[
("a.kicad_sym", symbol("First", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let second = h.bundle(
"second",
&[
("b.kicad_sym", symbol("Second", "Package")),
("Package.kicad_mod", footprint("Package", 2.0, "")),
],
);
ok(&h.import(&first, &[]));
assert!(!h.import(&second, &[]).status.success());
let pending = records(&h)
.into_iter()
.find(|r| r["status"] == "pending")
.unwrap();
let other = h.root.join("other/other.kicad_pro");
fs::create_dir_all(other.parent().unwrap()).unwrap();
fs::write(&other, "{}").unwrap();
h.policy("separate");
append_config(
&h,
&format!(
"[destination]\npolicy = \"project\"\nproject = {:?}",
other.to_str().unwrap()
),
);
ok(&h.cmd(&["retry", pending["id"].as_str().unwrap()]));
assert_eq!(h.footprints().len(), 2);
assert!(!other.parent().unwrap().join("libraries").exists());
}
#[test]
fn registered_footprint_uses_filename_as_library_identifier() {
let h = Harness::new("ask");
let pretty = h.root.join("registered.pretty");
fs::create_dir_all(&pretty).unwrap();
fs::write(
pretty.join("Actual.kicad_mod"),
footprint("InternalOldName", 1.0, ""),
)
.unwrap();
fs::write(h.root.join("project/fp-lib-table"),format!(r#"(fp_lib_table (lib (name "External") (type "KiCad") (uri "{}") (options "") (descr "")))"#,pretty.display())).unwrap();
// Keep the library inside the project to permit in-place portable reuse.
let local_pretty = h.root.join("project/registered.pretty");
fs::rename(&pretty, &local_pretty).unwrap();
fs::write(h.root.join("project/fp-lib-table"),format!(r#"(fp_lib_table (lib (name "External") (type "KiCad") (uri "{}") (options "") (descr "")))"#,local_pretty.display())).unwrap();
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Incoming")),
("Incoming.kicad_mod", footprint("Incoming", 1.0, "")),
],
);
ok(&h.import(&source, &[]));
assert_eq!(link(symbols(&h.library())[0]), "External:Actual");
}
#[test]
fn disabled_footprints_cannot_reference_an_unregistered_managed_directory() {
let h = Harness::new("ask");
append_config(&h, "[options]\nfootprints = false");
let pretty = h.root.join("project/libraries/UniLoader.pretty");
fs::create_dir_all(&pretty).unwrap();
fs::write(
pretty.join("Package.kicad_mod"),
footprint("Package", 1.0, ""),
)
.unwrap();
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("Part", "Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let result = h.import(&source, &[]);
// Registration without copying is also acceptable, but a successful import
// must never write a symbol link whose library nickname cannot be resolved.
if result.status.success() {
let table = parse(
&fs::read_to_string(h.root.join("project/fp-lib-table"))
.expect("successful import needs footprint registration"),
)
.unwrap();
assert!(table.children().iter().any(|n| n.head() == Some("lib")
&& n.field("name").and_then(Node::value) == Some("UniLoader")));
} else {
assert!(output(&result).contains("pending"));
assert!(
!h.root
.join("project/libraries/UniLoader.kicad_sym")
.exists()
);
}
}
#[test]
fn qualified_existing_reference_is_not_silently_retargeted_by_bundle_basename() {
let h = Harness::new("separate");
let pretty = h.root.join("project/existing.pretty");
fs::create_dir_all(&pretty).unwrap();
fs::write(
pretty.join("Package.kicad_mod"),
footprint("Package", 5.0, ""),
)
.unwrap();
fs::write(h.root.join("project/fp-lib-table"),format!(r#"(fp_lib_table (lib (name "Existing") (type "KiCad") (uri "{}") (options "") (descr "")))"#,pretty.display())).unwrap();
let source = h.bundle(
"source",
&[
("a.kicad_sym", symbol("First", "Incoming:Package")),
("b.kicad_sym", symbol("Second", "Existing:Package")),
("Package.kicad_mod", footprint("Package", 1.0, "")),
],
);
let result = h.import(&source, &[]);
if result.status.success() {
let lib = h.library();
let second = symbols(&lib)
.into_iter()
.find(|s| s.value() == Some("Second"))
.unwrap();
assert_eq!(link(second), "Existing:Package");
} else {
assert!(output(&result).contains("pending"));
}
}
+81
View File
@@ -0,0 +1,81 @@
//! The native UI uses the installed binary and never needs a scripting runtime.
use std::{
fs,
io::Write,
process::{Command, Stdio},
};
fn internal_dialog(request: &str) -> (std::process::Output, tempfile::TempDir) {
let temp = tempfile::tempdir().unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_uniloader"))
.args([
"--config",
temp.path()
.join("must-not-be-created/config.toml")
.to_str()
.unwrap(),
"__dialog",
])
.env("HOME", temp.path())
.env("XDG_CONFIG_HOME", temp.path().join("config"))
.env("XDG_DATA_HOME", temp.path().join("data"))
// The binary must not search PATH for an interpreter or companion helper.
.env("PATH", temp.path().join("empty-path"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(request.as_bytes())
.unwrap();
(child.wait_with_output().unwrap(), temp)
}
#[test]
fn no_display_is_pending_without_config_or_external_runtime() {
for request in [
r#"{"mode":"destination","projects":[],"component":"part"}"#,
r#"{"mode":"compare","incoming":"(footprint A)","candidates":[]}"#,
r#"{"mode":"file","title":"model","paths":["/a.step","/b.step"]}"#,
] {
let (output, temp) = internal_dialog(request);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap(),
serde_json::Value::Null
);
assert_eq!(
fs::read_dir(temp.path()).unwrap().count(),
0,
"native helper must not load config or write state"
);
}
}
#[test]
fn malformed_internal_request_is_rejected_without_writes() {
let (output, temp) = internal_dialog("{bad JSON");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("invalid native dialog request"));
assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 0);
}
#[test]
fn internal_window_command_is_not_a_user_command() {
let output = Command::new(env!("CARGO_BIN_EXE_uniloader"))
.arg("--help")
.output()
.unwrap();
assert!(output.status.success());
assert!(!String::from_utf8_lossy(&output.stdout).contains("__dialog"));
}
+196
View File
@@ -0,0 +1,196 @@
//! Actual filesystem watcher subprocess: no display, user state or KiCad needed.
use std::{
fs,
io::{Cursor, Write},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
thread,
time::{Duration, Instant, SystemTime},
};
use zip::{ZipWriter, write::SimpleFileOptions};
struct Watcher(Child);
impl Drop for Watcher {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn until(label: &str, log: &Path, mut check: impl FnMut() -> bool) {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(5) {
if check() {
return;
}
thread::sleep(Duration::from_millis(40));
}
panic!(
"timed out waiting for {label}; watcher log:\n{}",
fs::read_to_string(log).unwrap_or_default()
);
}
fn archive(name: &str, pad: usize) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
let fp = format!(
r#"(footprint "{name}" (version 20240108) (layer "F.Cu") (pad "{pad}" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Paste" "F.Mask")))"#
);
let sym = format!(
r#"(kicad_symbol_lib (version 20231120) (generator "watch-fixture") (symbol "{name}" (property "Reference" "U") (property "Footprint" "{name}")))"#
);
for (file, data) in [
(format!("{name}.kicad_mod"), fp),
(format!("{name}.kicad_sym"), sym),
] {
zip.start_file(file, SimpleFileOptions::default()).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn completed(root: &Path) -> usize {
let Ok(entries) = fs::read_dir(root.join("data/uniloader/imports")) else {
return 0;
};
entries
.flatten()
.filter(|entry| {
fs::read(entry.path())
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|record| record["status"] == "completed")
})
.count()
}
fn snapshot(root: &Path) -> Vec<(PathBuf, Vec<u8>, SystemTime)> {
let mut files = vec![];
for tree in [root.join("project"), root.join("data/uniloader/imports")] {
for entry in walkdir::WalkDir::new(tree).into_iter().flatten() {
if entry.file_type().is_file() {
files.push((
entry.path().to_owned(),
fs::read(entry.path()).unwrap(),
entry.metadata().unwrap().modified().unwrap(),
));
}
}
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
#[test]
fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let downloads = root.join("downloads");
fs::create_dir_all(&downloads).unwrap();
let project = root.join("project/board.kicad_pro");
fs::create_dir_all(project.parent().unwrap()).unwrap();
fs::write(&project, "{}").unwrap();
let config = root.join("config.toml");
fs::write(&config, format!("[libraries]\npath = {:?}\nkicad_config = {:?}\n[watch]\ndirectories = [{:?}]\nsettle_seconds = 0\nretry_seconds = 2\n[destination]\npolicy = \"project\"\nproject = {:?}\n[matching]\nambiguous = \"separate\"\n[ui]\nenabled = false\n", root.join("global"), root.join("kicad"), downloads, project)).unwrap();
let log = root.join("watch.log");
let spawn = || {
let logfile = fs::File::create(&log).unwrap();
Watcher(
Command::new(env!("CARGO_BIN_EXE_uniloader"))
.args([
"--config",
config.to_str().unwrap(),
"--noninteractive",
"watch",
])
.env("HOME", root.join("home"))
.env("XDG_CONFIG_HOME", root.join("config"))
.env("XDG_DATA_HOME", root.join("data"))
.env("XDG_CACHE_HOME", root.join("cache"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("DBUS_SESSION_BUS_ADDRESS")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::from(logfile.try_clone().unwrap()))
.stderr(Stdio::from(logfile))
.spawn()
.unwrap(),
)
};
let watcher = spawn();
until("watcher readiness", &log, || {
fs::read_to_string(&log)
.unwrap_or_default()
.contains("Watching")
});
let first = archive("First", 1);
let second = archive("Independent", 2);
let third = archive("Renamed", 3);
// A corrupt/incomplete final filename must not block an independent ZIP.
fs::write(downloads.join("partial.zip"), &first[..20]).unwrap();
fs::write(downloads.join("independent.zip"), &second).unwrap();
// A completed .part is still not a component until its final rename.
fs::write(downloads.join("renamed.zip.part"), &third).unwrap();
until(
"independent component despite partial archive",
&log,
|| completed(root) == 1,
);
assert!(
!root
.join("project/libraries/UniLoader.pretty/First.kicad_mod")
.exists()
);
assert!(
!root
.join("project/libraries/UniLoader.pretty/Renamed.kicad_mod")
.exists()
);
fs::write(downloads.join("partial.zip.part"), &first).unwrap();
fs::rename(
downloads.join("partial.zip.part"),
downloads.join("partial.zip"),
)
.unwrap();
fs::rename(
downloads.join("renamed.zip.part"),
downloads.join("renamed.zip"),
)
.unwrap();
until("both renamed components", &log, || completed(root) == 3);
for (file, bytes) in [
("partial.zip", &first),
("independent.zip", &second),
("renamed.zip", &third),
] {
assert_eq!(
&fs::read(downloads.join(file)).unwrap(),
bytes,
"source archive must be preserved"
);
}
let before = snapshot(root);
// Duplicate creation and write events, including a new pathname for the
// same archive content, must not edit libraries/history again.
fs::write(downloads.join("duplicate.zip"), &first).unwrap();
fs::write(downloads.join("independent.zip"), &second).unwrap();
thread::sleep(Duration::from_millis(800));
assert_eq!(completed(root), 3);
assert_eq!(before, snapshot(root));
drop(watcher);
let restarted = spawn();
until("watcher restart", &log, || {
fs::read_to_string(&log)
.unwrap_or_default()
.contains("Watching")
});
thread::sleep(Duration::from_millis(800));
assert_eq!(completed(root), 3);
assert_eq!(
before,
snapshot(root),
"restart must not rewrite completed imports"
);
drop(restarted);
}