app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
AI Flow 0.2.41: test plan contact rule (#124), project-page library fixes (#125)
10 files changed
+496−39
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.40"+version = "0.2.41" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.40"+version = "0.2.41" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.40"+version = "0.2.41" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.40"+version = "0.2.41" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/main.rs+11−3@@ -5165,10 +5165,16 @@ fn main() { } else { let np = netlist.clone().unwrap_or_else(|| err("before the board, testplan check needs --netlist <netlist.json>", &[])); let mut nl = read_json(&np);- let (rows, gaps, unknown) = testplan::check_netlist(&list, &nl);+ let (mut rows, mut gaps, unknown) = testplan::check_netlist(&list, &nl); if !unknown.is_empty() { err(&format!("tests name nets the netlist does not have: {}", unknown.join(", ")), &["Use the netlist's own net names (or rename the net in the design so the measurement has a name).".into()]); }- let added = if *add && !gaps.is_empty() { let a = testplan::add_test_points(&mut nl, &gaps); write_json(Path::new(&np), &nl); a } else { vec![] };- (rows, if *add { vec![] } else { gaps }, vec![], added, "netlist")+ let fixable = testplan::addable(&rows);+ let added = if *add && !fixable.is_empty() {+ let a = testplan::add_test_points(&mut nl, &fixable); write_json(Path::new(&np), &nl);+ // the table and the gaps as the netlist now stands: only a ground test with no contact is left+ let again = testplan::check_netlist(&list, &nl); rows = again.0; gaps = again.1;+ a+ } else { vec![] };+ (rows, gaps, vec![], added, "netlist") }; let table: Vec<String> = rows.iter().map(|x| format!(" {:<10} {:<12} {:<9} {}", x["id"].as_str().unwrap_or(""), x["net"].as_str().unwrap_or(""), x["kind"].as_str().unwrap_or(""), match x["status"].as_str().unwrap_or("") { "gap" => format!("GAP: {}", x["why"].as_str().unwrap_or("no test point")), s => format!("{s} {}", x["spot"].as_object().map(|o| format!("{} at ({}, {})", o["pad"].as_str().unwrap_or(""), o["x"], o["y"])).or_else(|| x["spot"].as_str().map(str::to_string)).unwrap_or_default()) })).collect(); r.outcome("testplan", json!({"mode": mode, "tests": rows, "gaps": gaps, "added": added, "notes": notes, "file": tp_abs}));@@ -5176,6 +5182,8 @@ fn main() { let head = format!("test plan ({mode}): {} measurements, {} probe-able, {} gap(s){}", rows.len(), rows.iter().filter(|x| x["status"] != "gap").count(), gaps.len(), if added.is_empty() { String::new() } else { format!("; added {}", added.join(", ")) }); let body = format!("{head}\n{}", table.join("\n")); if !gaps.is_empty() {+ let tether_hint = "A ground test needs a free MachineContactMedium on that net for the tether's medium pin: add one to the design (a test point or a large contact cannot take it).".to_string();+ if testplan::ground_gap(&rows) && testplan::addable(&rows).is_empty() { err(&body, &[tether_hint]); } err(&body, &[if mode == "board" { "Each gap needs a spot: move a part off the pad, add a test point on the net (adom/testpoint-pad-0-5mm, schematic first: `step schematic --back --why \"test point for <net>\"`), or widen the copper so a 0.5 mm landing fits. Then `testplan check` again.".into() } else { "Add a test point per gap: `testplan check --netlist <netlist.json> --add` writes TP<n> (0.5 mm round, adom/testpoint-pad-0-5mm) into the netlist.".into() }]); } let mut hints = vec!["Read the table to the human: what you will measure on the workcell and where the probe lands. Every added test point goes into the library and the schematic like any part.".to_string()];
crates/adom-aiflow/src/testplan.rs+144−10@@ -6,6 +6,9 @@ //! no test point gets one (`--add` writes TP<n> into the netlist). Once the board exists it looks for a //! real landing on each net: a test point or an exposed pad where a 0.5 mm pogo landing fits clear of //! every part body and of other nets' copper, within the ground tether's reach of a ground contact.+//! Both modes use one rule for machine contacts (issue #124): a MachineContactLarge is a landing, on its+//! ring; a MachineContactMedium is not (its ring is thinner than the landing), but it is the only thing+//! a `ground` test can use, because the tether ends in a medium pin. A test point cannot take a tether. //! Workcell numbers are Stephen's (adom/probing-workcell): smallest pad 200 um, under 300 um needs a //! human crosshair check, 50 mm tether reach from a MachineContactMedium, tips about 3 mm apart. @@ -26,6 +29,28 @@ pub const REACH: f64 = 50.0; pub fn needs_spot(kind: &str) -> bool { !matches!(kind, "camera") } +/// A machine contact by its name: Some(true) large, Some(false) medium (or a contact of no stated size).+fn contact_class(name: &str) -> Option<bool> {+ let n = name.to_ascii_lowercase();+ n.contains("machinecontact").then(|| n.contains("machinecontactlarge"))+}++fn no_tether_contact(net: &str) -> String {+ format!("no MachineContactMedium on {net}: the ground tether plugs into one, and a test point cannot take it")+}++/// The nets `--add` can give a spot to: a `ground` test needs a contact, not a test point.+pub fn addable(rows: &[Value]) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for r in rows.iter().filter(|r| r["status"] == "gap" && r["kind"] != "ground") {+ if let Some(n) = r["net"].as_str() { if !out.iter().any(|x| x == n) { out.push(n.to_string()); } }+ }+ out+}++/// Whether any gap is a `ground` test with no contact to plug into.+pub fn ground_gap(rows: &[Value]) -> bool { rows.iter().any(|r| r["status"] == "gap" && r["kind"] == "ground") }+ pub fn tests_of(v: &Value) -> Vec<Value> { let mut list = v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default(); // a plan-wide probe face ({"probeFace": "bottom", "tests": [...]}) applies to every test without its own@@ -51,10 +76,15 @@ pub fn nets_of(netlist: &Value) -> Vec<(String, Vec<String>)> { }).collect() } -/// Netlist mode: (rows, gaps). A gap is a measured net with no test point on it.+/// Netlist mode: (rows, gaps, unknown nets). A gap is a measured net with no landing on it: no test point+/// and no large machine contact, or for a `ground` test no medium contact. pub fn check_netlist(tests: &[Value], netlist: &Value) -> (Vec<Value>, Vec<String>, Vec<String>) { let nets = nets_of(netlist);- let tp_refs: Vec<String> = netlist["components"].as_array().cloned().unwrap_or_default().iter().filter(|c| is_tp(c)).filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();+ let comps = netlist["components"].as_array().cloned().unwrap_or_default();+ let refs_where = |keep: &dyn Fn(&Value) -> bool| -> Vec<String> { comps.iter().filter(|c| keep(c)).filter_map(|c| c["ref"].as_str().map(str::to_string)).collect() };+ let tp_refs = refs_where(&is_tp);+ let large_refs = refs_where(&|c| contact_class(&part_text(c)) == Some(true));+ let medium_refs = refs_where(&|c| contact_class(&part_text(c)) == Some(false)); let (mut rows, mut gaps, mut unknown) = (Vec::new(), Vec::new(), Vec::new()); for t in tests { let net = t["net"].as_str().unwrap_or("");@@ -63,17 +93,35 @@ pub fn check_netlist(tests: &[Value], netlist: &Value) -> (Vec<Value>, Vec<Strin if needs_spot(kind) { unknown.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": null, "status": "unknown net"})); continue; };- let tp = pins.iter().find(|p| tp_refs.iter().any(|r| p.split('.').next() == Some(r.as_str())));- let status = if !needs_spot(kind) { "camera" } else if tp.is_some() { "test point" } else { "gap" };+ let on = |refs: &[String]| pins.iter().find(|p| refs.iter().any(|r| p.split('.').next() == Some(r.as_str())));+ if kind == "ground" {+ match on(&medium_refs) {+ Some(pin) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": pin, "status": "contact"})),+ None => {+ if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); }+ rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": null, "status": "gap", "why": no_tether_contact(net)}));+ }+ }+ continue;+ }+ let tp = on(&tp_refs);+ let large = on(&large_refs);+ let status = if !needs_spot(kind) { "camera" } else if tp.is_some() { "test point" } else if large.is_some() { "contact" } else { "gap" }; if status == "gap" && !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); }- rows.push(json!({"id": t["id"], "net": net, "kind": kind, "spot": tp, "status": status}));+ let mut row = json!({"id": t["id"], "net": net, "kind": kind, "spot": tp.or(large), "status": status});+ if status == "gap" && on(&medium_refs).is_some() { row["why"] = json!("no test point, and a MachineContactMedium is not a landing (its ring is thinner than the 0.5 mm landing)"); }+ rows.push(row); } (rows, gaps, unknown) } +fn part_text(c: &Value) -> String {+ format!("{} {} {} {}", c["fp"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or(""), c["page"].as_str().unwrap_or(""), c["mpn"].as_str().unwrap_or("")).to_ascii_lowercase()+}+ fn is_tp(c: &Value) -> bool { let r = c["ref"].as_str().unwrap_or("");- let txt = format!("{} {} {} {}", c["fp"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or(""), c["page"].as_str().unwrap_or(""), c["mpn"].as_str().unwrap_or("")).to_ascii_lowercase();+ let txt = part_text(c); r.starts_with("TP") || txt.contains("testpoint") || txt.contains("test_point") || txt.contains("test point") } @@ -153,20 +201,33 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< let sided = sided_bodies(b); let bodies_on = |front: bool| -> Vec<(String, (f64, f64, f64, f64))> { sided.iter().filter(|(_, f, _)| *f == front).map(|(r, _, bb)| (r.clone(), *bb)).collect() }; let (front_bodies, back_bodies) = (bodies_on(true), bodies_on(false));- let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| fp.lib.to_ascii_lowercase().contains("machinecontact"))+ let contact_refs = |large: bool| -> Vec<String> { b.footprints.iter().filter(|(_, fp)| contact_class(&fp.lib) == Some(large)).map(|(r, _)| r.clone()).collect() };+ let (large_refs, medium_refs) = (contact_refs(true), contact_refs(false));+ // the tether ends in a medium pin: it plugs into a medium contact, never a large one+ let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| contact_class(&fp.lib) == Some(false)) .flat_map(|(r, _)| b.pads_of(r).into_iter().filter(|p| p.net.eq_ignore_ascii_case("GND")).map(move |p| (p.x, p.y, r.clone()))).collect(); let (mut rows, mut gaps, mut warn) = (Vec::new(), Vec::new(), Vec::new());- if grounds.is_empty() { warn.push("no MachineContact on GND: the ground tether has nowhere to plug in (add one near each corner, adom/probing-workcell)".into()); }+ if grounds.is_empty() { warn.push("no MachineContactMedium on GND: the ground tether has nowhere to plug in (add one near each corner, adom/probing-workcell)".into()); } for t in tests { let net = t["net"].as_str().unwrap_or(""); let kind = t["kind"].as_str().unwrap_or("dc"); if !needs_spot(kind) { rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "camera"})); continue; }+ if kind == "ground" {+ let contact = b.pads.iter().find(|p| p.net == net && medium_refs.contains(&p.reference));+ match contact {+ Some(p) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "contact", "spot": {"pad": p.key, "x": (p.x * 100.0).round() / 100.0, "y": (p.y * 100.0).round() / 100.0}})),+ None => { if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "gap", "why": no_tether_contact(net)})); }+ }+ continue;+ } let mut best: Option<(f64, f64, String, f64, bool)> = None; let mut why = "no exposed copper on this net".to_string(); let faces = faces_of(t); for &front in &faces { let bodies = if front { &front_bodies } else { &back_bodies }; for p in b.pads.iter().filter(|p| p.net == net && if front { p.on_f } else { p.on_b }) {+ if medium_refs.contains(&p.reference) { why = format!("{} is a MachineContactMedium: its ring is thinner than the 0.5 mm landing", p.key); continue; }+ let large = large_refs.contains(&p.reference); // sample landing centres inside the pad, shrunk by the landing radius let (hx, hy) = (p.size.0 / 2.0 - LANDING_R, p.size.1 / 2.0 - LANDING_R); if hx < -1e-9 || hy < -1e-9 { why = format!("pads too small for a 0.5 mm landing (e.g. {} {:.2}x{:.2} mm)", p.key, p.size.0, p.size.1); continue; }@@ -176,7 +237,13 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< let ly = if steps > 0 { -hy + 2.0 * hy * j as f64 / steps as f64 } else { 0.0 }; let (dx, dy) = geom::rot_kicad(lx, ly, p.angle); let c = (p.x + dx, p.y + dy);- if let Some((r, _)) = bodies.iter().find(|(_, bb)| in_box(c, bb, LANDING_R)) {+ // a large contact is probed on its ring: clear of the hole and on the copper+ if large {+ let from_centre = dx.hypot(dy);+ if from_centre - LANDING_R < p.drill.unwrap_or(0.0) / 2.0 - 1e-9 || from_centre + LANDING_R > p.size.0 / 2.0 + 1e-9 { continue; }+ }+ // and its own fab outline is no body over its own pad+ if let Some((r, _)) = bodies.iter().find(|(r, bb)| !(large && *r == p.reference) && in_box(c, bb, LANDING_R)) { why = format!("every landing on {net} is under a part body (e.g. {} under {r})", p.key); continue; } if b.pads.iter().any(|q| q.net != net && (if front { q.on_f } else { q.on_b }) && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) {@@ -191,7 +258,7 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec< } } match best {- Some((x, y, pad, _, front)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "face": if front { "top" } else { "bottom" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})),+ Some((x, y, pad, _, front)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else if large_refs.iter().any(|r| pad.split('.').next() == Some(r.as_str())) { "contact" } else { "exposed pad" }, "face": if front { "top" } else { "bottom" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})), None => { if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "gap", "why": why})); } } }@@ -216,6 +283,73 @@ mod tests { let (_, gaps2, _) = check_netlist(&tests, &nl); assert!(gaps2.is_empty()); }+ /// The real contact footprints' shape, and three test kinds on them.+ fn contacts_board(medium_fab: &str) -> Board {+ let text = format!(r#"(kicad_pcb+ (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+ (net 1 "SIG") (net 2 "GND") (net 3 "VBAT") (net 4 "PGND")+ (gr_rect (start 100 100) (end 140 120) (layer "Edge.Cuts"))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 110 110) (property "Reference" "J1") {medium_fab}+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu" "*.Mask") (net 1 "SIG")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 120 110) (property "Reference" "J2") {medium_fab}+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu" "*.Mask") (net 2 "GND")))+ (footprint "Molecule:MachineContactLarge" (layer "F.Cu") (at 130 110) (property "Reference" "J3")+ (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 3 "VBAT")))+ (footprint "Molecule:MachineContactLarge" (layer "F.Cu") (at 130 103) (property "Reference" "J4")+ (pad "1" thru_hole circle (at 0 0) (size 4.4 4.4) (drill 2.62) (layers "*.Cu" "*.Mask") (net 4 "PGND"))))"#);+ Board::from_text("test.kicad_pcb", text).unwrap()+ }+ fn contact_tests() -> Vec<Value> {+ vec![json!({"id": "sig", "net": "SIG", "kind": "waveform"}), json!({"id": "vbat", "net": "VBAT", "kind": "dc"}), json!({"id": "gnd", "net": "GND", "kind": "ground"})]+ }+ #[test]+ fn netlist_mode_counts_a_large_contact_and_the_tether_contact() {+ // issue #124: VBAT on a large contact and the ground test on a medium one need no test point+ let mut nl = json!({"components": [{"ref": "J1", "fp": "Molecule:MachineContactMedium"}, {"ref": "J2", "page": "adom/machinecontactmedium"}, {"ref": "J3", "fp": "Molecule:MachineContactLarge"}],+ "nets": [["SIG", ["J1.1"]], ["GND", ["J2.1"]], ["VBAT", ["J3.1"]]]});+ let (rows, gaps, _) = check_netlist(&contact_tests(), &nl);+ assert_eq!(rows[1]["status"], "contact"); assert_eq!(rows[1]["spot"], "J3.1");+ assert_eq!(rows[2]["status"], "contact"); assert_eq!(rows[2]["spot"], "J2.1");+ // a medium contact is not a landing for a measurement: SIG gets a test point+ assert_eq!(rows[0]["status"], "gap"); assert!(rows[0]["why"].as_str().unwrap().contains("Medium"));+ assert_eq!(gaps, ["SIG"]); assert_eq!(addable(&rows), ["SIG"]);+ add_test_points(&mut nl, &addable(&rows));+ let (rows, gaps, _) = check_netlist(&contact_tests(), &nl);+ assert!(gaps.is_empty()); assert_eq!(rows[0]["status"], "test point");+ }+ #[test]+ fn a_ground_test_needs_a_medium_contact_and_never_gets_a_test_point() {+ // only a large contact on GND: the medium tether pin does not fit it+ for comp in [json!({"ref": "J1", "fp": "Molecule:MachineContactLarge"}), json!({"ref": "R1", "fp": "R_0402"})] {+ let nl = json!({"components": [comp], "nets": [["GND", ["J1.1", "R1.1"]]]});+ let (rows, gaps, _) = check_netlist(&[json!({"id": "g", "net": "GND", "kind": "ground"})], &nl);+ assert_eq!(rows[0]["status"], "gap"); assert_eq!(gaps, ["GND"]);+ assert!(addable(&rows).is_empty(), "--add must not put a test point on it"); assert!(ground_gap(&rows));+ }+ }+ #[test]+ fn board_mode_lands_on_a_large_ring_and_not_on_a_medium_contact() {+ // the wiki footprint has no fab outline, the library's has a 2 x 2 mm one: the same answer for both+ for fab in ["", r#"(fp_rect (start -1 -1) (end 1 1) (layer "F.Fab"))"#] {+ let (rows, gaps, warn) = check_board(&contact_tests(), &contacts_board(fab));+ assert!(warn.is_empty(), "{warn:?}");+ assert_eq!(gaps, ["SIG"], "{rows:?}");+ assert!(rows[0]["why"].as_str().unwrap().contains("J1.1 is a MachineContactMedium"), "{rows:?}");+ assert_eq!(rows[1]["status"], "contact");+ // the spot is on the copper ring, clear of the hole+ let (x, y) = (rows[1]["spot"]["x"].as_f64().unwrap() - 130.0, rows[1]["spot"]["y"].as_f64().unwrap() - 110.0);+ let r = x.hypot(y);+ assert!(r - LANDING_R >= 2.62 / 2.0 - 0.01 && r + LANDING_R <= 2.2 + 0.01, "landing at r = {r}");+ assert_eq!(rows[2]["status"], "contact"); assert_eq!(rows[2]["spot"]["pad"], "J2.1");+ }+ }+ #[test]+ fn board_mode_ground_test_refuses_a_plain_pad_or_a_large_contact() {+ let b = contacts_board("");+ let (rows, gaps, _) = check_board(&[json!({"id": "g", "net": "PGND", "kind": "ground"})], &b);+ assert_eq!(rows[0]["status"], "gap"); assert_eq!(gaps, ["PGND"]);+ assert!(rows[0]["why"].as_str().unwrap().contains("MachineContactMedium"));+ } /// A front test-point pad on VCC at (5, 5) and a second part whose body sits over it on `body_fab`. fn pad_and_body(body_side: &str, body_fab: &str) -> Board { let text = format!(r#"(kicad_pcb
docs/release-0.2.41.mdadded+19@@ -0,0 +1,19 @@+# AI Flow 0.2.41 (2026-11-02)++Test plan (#124):++- **A large machine contact now counts as a probe spot in both modes.** `testplan check --netlist --add` no longer adds a test point on a net that already has a `MachineContactLarge`. On the board, the check lands on the contact's ring, clear of the hole. Before this, board mode reported every contact as covered by its own body, so a test point removed in the netlist step came back as a gap at `finish`.+- **A medium contact is not a probe spot, for now.** Its ring is about 0.3 mm wide around the bore, thinner than the 0.5 mm landing, so a net with only a `MachineContactMedium` keeps its test point. The gap now says why.+- **A `ground` test needs a `MachineContactMedium` on that net,** because the tether ends in a medium pin. `--add` no longer puts a test point on it, since a test point can't take the tether. Board mode no longer accepts a plain pad or a large contact for a ground test, and the 50 mm reach is measured from medium contacts only.++If your board was checked with an earlier version: a `ground` test with no medium contact, and a measurement with only a medium contact, are now gaps.++Project page (#125):++- **A library that won't load now names the part.** When KiCad refuses the packaged library, each symbol is loaded alone and the error reads `<MPN> (<page>): KiCad cannot load this symbol`.+- **A rerun drops parts that left the BOM.** The board's Cloud Library is reused, and items no longer on the BOM are removed before export. The hints list what was removed.+- **A failed `--create` no longer leaves a page behind.** The page is made only after the library passes its checks, so "Nothing was published" is now true and the same `--create` works on the rerun. If the push itself fails after the page is made, a rerun with `--create` finishes that page.++The two component pages in #125 still need their own fix: adom/bsc016n06ns has an open PR, and adom/cl05b104kb54pnc has none yet.++Checked with the real contact footprints from the wiki and the Adom library (test plan). The per-symbol check ran on a real kicad-cli, which refused both pages and named the right part beside a good symbol. The rest of the project-page flow ran against a stand-in `adom-wiki`, with a real Cloud Library for the `show` and `remove` replies.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.40",+ "version": "0.2.41", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.40",+ "version": "0.2.41", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/aiflow-test-plan/SKILL.md+6−4@@ -29,7 +29,7 @@ Write `tests.json`, one entry per measurement, from THIS circuit and its simulat ``` Kinds: `dc`, `ripple`, `waveform` need a probe landing; `camera` (an LED, a display) needs none;-`ground` is the tether's machine contact. Typical for a regulator: the input at the board, the output,+`ground` is the tether's machine contact, which must be a free `MachineContactMedium` on that net (the tether ends in a medium pin; a test point or a large contact cannot take it). Typical for a regulator: the input at the board, the output, the switch node, both inductor ends, feedback, enable, power-good, the bulk caps' ripple, every LED. Probe face: a test is probed from the top unless it says otherwise. `"face": "bottom"` (or `"back"`) on a@@ -41,13 +41,15 @@ reports the face each landing was found on. `adom-aiflow testplan check --tests tests.json --netlist <netlist.json> --add` maps each measurement to its net and adds `TP<n>` (adom/testpoint-pad-0-5mm: a 0.5 mm round exposed-copper pad, no paste, a 1.1 mm-courtyard) on every net that has none. The test points go into the library and the schematic like any+courtyard) on every net with no landing. A `MachineContactLarge` already is a landing, so its net gets no+test point. A `MachineContactMedium` is not one for now (see below), and neither is a corner pin. A `ground`+test with no medium contact is a gap `--add` cannot fix: add the contact. The test points go into the library and the schematic like any part. Read the table to the human: what you will measure and where the probe lands. ## 3. Check again on the real board Run `testplan check` again after placement and before finish (finish runs it itself). On a board it-looks for a real landing on each net: a test point, or an exposed pad where a 0.5 mm landing fits clear+looks for a real landing on each net: a test point, the ring of a large contact, or an exposed pad where a 0.5 mm landing fits clear of every part body (an inductor's pads under its body, a 1206's end caps, an IC's pins do not count) and 0.3 mm from other nets' copper, within 50 mm of a ground machine contact. A gap names why; fix it by moving a part, adding a test point (back to the schematic), or widening copper.@@ -66,4 +68,4 @@ moving a part, adding a test point (back to the schematic), or widening copper. - At least 1.5 mm centre to centre from every other test pad, so a probe lands on one pad only; DRC passing is not enough (two pads 0.86 mm apart pass DRC and still share a probe). - Out of the machine-contact columns and corner-pin zones: the contacts keep their grid positions and their silkscreen tags; the test pads move.-- A net that can already be probed on a contact or a corner pin needs no test pad (GND on the contacts and corner pins): leave it out rather than crowd the board.+- A net on a `MachineContactLarge` needs no test pad: its ring is about 1 mm wide, clear of the bore, so the landing fits. A net only on a `MachineContactMedium` or a corner pin keeps its test pad for now: a medium contact's ring is about 0.3 mm wide around the bore, thinner than the 0.5 mm landing, and the human crosshair check for pads that small is not built.
tools/project-page.py+104−9@@ -213,11 +213,41 @@ def board_library(name, recorded, work): return ref, notes +def drop_stale_items(ref, pages):+ """A reused library holds this BOM and nothing else (issue #125): a part taken off the BOM must not stay+ in the export. Runs before the adds, so an item a renamed page no longer matches is simply added back.+ Returns (removed labels, retry waits); only this board's own library ever reaches here."""+ ok, out, waits = run_retrying(["adom-wiki", "library", "show", ref, "--json"])+ try:+ items = payload(json.loads(out[out.index("{"):out.rindex("}") + 1]), "items") if ok else None+ except ValueError:+ items = None+ if not isinstance(items, list):+ fail(f"could not read the Cloud Library {ref} to compare it with the BOM: " + clip(out.strip(), 300), cloudLibrary=ref)+ want = {p.lower() for p in pages}+ removed, failed = [], []+ for it in items:+ page = f"{it.get('owner')}/{it.get('slug')}" if it.get("owner") and it.get("slug") else None+ if (page and page.lower() in want) or str(it.get("ref", "")).lower() in want:+ continue+ ok, out, w = run_retrying(["adom-wiki", "library", "remove", ref, str(it.get("id")), "--json"])+ waits += w+ label = f"{it.get('mpn') or it.get('title') or it.get('ref')} ({page or 'page gone'})"+ (removed if ok else failed).append(label)+ if failed:+ fail(f"parts no longer on the BOM could not be removed from the Cloud Library {ref}: " + ", ".join(failed), cloudLibrary=ref)+ return removed, waits++ def cloud_library(name, parts, work, recorded=None):- """Reuse (or create once) the board's Cloud Library, add every part page with 429 retries, export the KiCad bundle."""+ """Reuse (or create once) the board's Cloud Library, make it hold this BOM (dropping parts no longer on+ it, adding every part page with 429 retries), export the KiCad bundle.""" ref, notes = board_library(name, recorded, work) pages = list(dict.fromkeys(p["wiki"] for p in parts if p.get("wiki")))- failed, waits = [], []+ removed, waits = drop_stale_items(ref, pages)+ if removed:+ notes.append("removed from the Cloud Library, no longer on the BOM: " + ", ".join(removed))+ failed = [] for i in range(0, len(pages), ADD_CHUNK): chunk = pages[i:i + ADD_CHUNK] ok, out, w = run_retrying(["adom-wiki", "library", "add", ref, *chunk, "--json"])@@ -244,7 +274,7 @@ def cloud_library(name, parts, work, recorded=None): report = {} if not ok or not os.path.isfile(bundle): fail("library export wrote no bundle: " + clip(out.strip(), 400), cloudLibrary=ref)- return ref, bundle, report, {"addedPages": len(pages), "rateLimitWaits": waits, "notes": notes}+ return ref, bundle, report, {"addedPages": len(pages), "removedStale": removed, "rateLimitWaits": waits, "notes": notes} # --- S-expressions: enough to count, extract and parse-check KiCad library files ---@@ -395,6 +425,42 @@ def kicad_check(sym_path): return "unavailable", f"{kc} exit {p.returncode}: {clip(text, 200)}" +def broken_symbols(sym_path, parts=()):+ """Which symbols stop KiCad loading the library (issue #125): each one is loaded alone, with its parent+ when it extends one. Returns [{symbol, mpn, page, label}]; empty when each loads alone, or when no+ kicad-cli can say."""+ text = open(sym_path).read()+ syms = top_symbols(text)+ if not syms:+ return []+ header = text[:min(a for a, _ in syms.values())]+ by_norm = {}+ for p in parts:+ for key in (p.get("mpn"), (p.get("wiki") or "").rsplit("/", 1)[-1]):+ if key:+ by_norm.setdefault(norm(key), p)+ work = tempfile.mkdtemp(prefix="aiflow-symblame-")+ bad = []+ try:+ for name, (a, b) in syms.items():+ blocks, cur, seen = [], name, set()+ while cur in syms and cur not in seen:+ seen.add(cur)+ blocks.insert(0, text[syms[cur][0]:syms[cur][1]])+ m = re.search(r'\(extends\s+"?([^")\s]+)"?', text[syms[cur][0]:syms[cur][1]])+ cur = m.group(1) if m else None+ one = os.path.join(work, "one.kicad_sym")+ open(one, "w").write(header + "\n".join(blocks) + "\n)\n")+ status, _ = kicad_check(one)+ if status == "fail":+ p = by_norm.get(norm(name)) or {}+ label = f"{p.get('mpn') or name}" + (f" ({p['wiki']})" if p.get("wiki") else "")+ bad.append({"symbol": name, "mpn": p.get("mpn"), "page": p.get("wiki"), "label": label})+ finally:+ shutil.rmtree(work, ignore_errors=True)+ return bad++ def norm(s): return re.sub(r"[^A-Z0-9]", "", (s or "").upper()) @@ -575,9 +641,15 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts header = SYM_VERSION problems = structural_check(sp, fp_dir) if os.path.isfile(sp) else ["no symbol library"] kstatus, kdetail = kicad_check(sp) if not problems else ("skipped", "the structural parse failed")+ broken = [] if kstatus == "fail":- problems.append("KiCad cannot load the packaged symbol library: " + kdetail)- check = {"symVersion": header, "structural": "pass" if not problems else "fail", "kicadCli": kstatus, "kicadCliDetail": kdetail, "problems": problems}+ broken = broken_symbols(sp, parts)+ if broken:+ problems.append("; ".join(f"{b['label']}: KiCad cannot load this symbol" for b in broken) ++ ". Fix or replace that part's symbol on its wiki page, or change the part, then run project-page again")+ else:+ problems.append("KiCad cannot load the packaged symbol library, though each symbol loads alone: " + kdetail)+ check = {"symVersion": header, "structural": "pass" if not problems else "fail", "kicadCli": kstatus, "kicadCliDetail": kdetail, "problems": problems, "brokenSymbols": broken} if problems: fail("the packaged KiCad library does not load: " + "; ".join(problems), libraryCheck=check) has_wiki = any(p.get("wiki") for p in parts)@@ -664,12 +736,25 @@ def main(): if folder and (cfg.get("create") or (cfg.get("push") and not exists)): # a folder of a page: the page is the project's and must exist; this never makes one raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} " + ("does not exist" if not cfg.get("create") else "is named with --create") + f": --page-dir {folder} writes into a folder of an existing page and never creates one"}))- if exists and not cfg.get("existing") and not folder:+ # a page this run created and has not finished (issue #125): a rerun carries on, README included+ made_rec = os.path.join(work, "page-created.json")+ try:+ unfinished = json.load(open(made_rec)).get("page") == cfg["page"]+ except (OSError, ValueError):+ unfinished = False+ if exists and unfinished:+ created = True+ if exists and not cfg.get("existing") and not folder and not unfinished: # a new board gets its own new page: never add one board's library to another board's (or anyone's) page raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} already exists. A new board gets its own new page: choose a new slug (--create). Pass --existing only when that page is this same board's own page from an earlier run."}))- if cfg.get("push") and not exists: # a dry run (--no-push) builds everything locally- if not cfg.get("create"):- raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} does not exist; pass --create to make it"}))+ if cfg.get("push") and not exists and not cfg.get("create"): # a dry run (--no-push) builds everything locally+ raise SystemExit(json.dumps({"ok": False, "error": f"{cfg['page']} does not exist; pass --create to make it"}))++ def make_page():+ """The page is made only once there is something to put on it, so a failed library check leaves none."""+ nonlocal created+ if not cfg.get("push") or exists:+ return args = ["adom-wiki", "page", "create", slug, "--type", "hardware", "--kind", "board", "--title", cfg["title"], "--brief", cfg["brief"][:200], "--because", "a new board designed by adom-aiflow: its own project page, one page per board", "--headline", cfg["title"][:60], "--subhead", cfg["brief"][:100], "--source", "altium" if cfg.get("eda") == "altium" else "kicad"]@@ -677,14 +762,22 @@ def main(): if owner != me: args += ["--org", owner] if cfg.get("private"): args.append("--private") sh(args); created = True+ json.dump({"page": cfg["page"]}, open(made_rec, "w"))++ def page_finished():+ if os.path.exists(made_rec):+ os.remove(made_rec)+ lib, pkgid = (lib_name(f"{slug}-{folder.rsplit('/', 1)[-1]}"), pkg_id(owner, f"{slug}.{folder}")) if folder else (lib_name(slug), pkg_id(owner, slug)) version = cfg.get("version") or "1.0.0" if cfg.get("eda") == "altium": # Altium: the library step builds the SchLib and PcbLib from the same wiki pages; no KiCad PCM package open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, None)) pushed = None+ make_page() if cfg.get("push") and readme_wanted(cfg, folder, created): pushed = push_files(cfg, work, folder, ["README.md"], "Revision 1: the chosen parts and the component table (adom-aiflow, Altium Designer)")+ page_finished() print(json.dumps({"ok": True, "eda": "altium", "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": None, "libName": lib, "identifier": None, "repositoryUrl": None, "parts": len(cfg["parts"]), "counts": {}, "libraryCheck": {}, "adds": {"addedPages": 0, "rateLimitWaits": [], "notes": []}, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],@@ -713,10 +806,12 @@ def main(): repo_url = f"{files_url}/pcm/repository.json" open(os.path.join(work, "README.md"), "w").write(readme(cfg, lib, pkgid, repo_url)) pushed = None+ make_page() if cfg.get("push"): files = [f"pcm/{zname}", "pcm/packages.json", "pcm/repository.json"] if readme_wanted(cfg, folder, created): files.insert(0, "README.md") pushed = push_files(cfg, work, folder, files, "Revision 1: the chosen parts, the component table and the KiCad PCM repository (adom-aiflow)")+ page_finished() print(json.dumps({"ok": True, "page": cfg["page"], "pageDir": folder, "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib, "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check, "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],
tools/test_project_page.pyadded+199@@ -0,0 +1,199 @@+"""Tests for tools/project-page.py against a stub adom-wiki and a stub kicad-cli (no network, no KiCad):+python3 tools/test_project_page.py"""+import importlib.util, json, os, stat, sys, tempfile, unittest, zipfile+from pathlib import Path+from unittest import mock++_spec = importlib.util.spec_from_file_location('project_page', Path(__file__).with_name('project-page.py'))+pp = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(pp)++HEADER = '(kicad_symbol_lib (version 20250114) (generator t)\n'+GOOD = '(symbol "GOOD1" (property "Reference" "R" (at 0 0 0)) (property "Footprint" "x:GOOD1" (at 0 0 0)))'+CHILD = '(symbol "CHILD1" (extends "GOOD1"))'+# noah's two pages: written without quotes+BAD = '(symbol bsc016n06ns (property Reference Q (at 0 0 0)))'++# refuses a library holding an unquoted symbol name, the way KiCad 10 refuses it+KICAD_CLI = '''#!/usr/bin/env python3+import re, sys+args = sys.argv[1:]+out = args[args.index('-o') + 1]+text = open(args[-1]).read()+if re.search(r'\\(symbol [^"(]', text):+ print('Unable to load library'); sys.exit(1)+if '(extends' in text and '(symbol "GOOD1"' not in text:+ print('Unable to load library'); sys.exit(1)+open(out, 'w').write(text)+'''++# stateful adom-wiki: pages and library items live in a JSON file, every call is logged+WIKI = '''#!/usr/bin/env python3+import json, os, sys, zipfile+state_path = os.environ['STUB_STATE']+st = json.load(open(state_path))+a = sys.argv[1:]+st['calls'].append(a)+def done(obj=None, code=0):+ json.dump(st, open(state_path, 'w'))+ if obj is not None: print(json.dumps(obj))+ sys.exit(code)+if a[:2] == ['repo', 'show']:+ done(code=0 if a[2] in st['pages'] else 1)+if a[:2] == ['page', 'create']:+ st['pages'].append('adom/' + a[2]); done({'status': 'ok'})+if a[0] == 'whoami':+ done({'data': {'user': {'username': 'adom'}}})+if a[:2] == ['repo', 'push']:+ done({'commit': 'abcdef1'}, 1 if st.get('pushFails') else 0)+if a[0] == 'library':+ v = a[1]+ if v == 'list': done({'data': {'libraries': st['libs']}})+ if v == 'create':+ st['libs'].append({'ref': 'lib_x', 'name': a[2], 'active': True}); done({'data': {'ref': 'lib_x'}})+ if v == 'activate': done({})+ if v == 'show': done({'status': 'ok', 'data': {'data': {'items': st['items']}}})+ if v == 'remove':+ st['items'] = [i for i in st['items'] if str(i['id']) != a[3]]; done({'removed': True})+ if v == 'add':+ for pg in [x for x in a[3:] if '/' in x]:+ if not any(f"{i['owner']}/{i['slug']}" == pg for i in st['items']):+ o, s = pg.split('/'); st['items'].append({'id': len(st['calls']) + 1000, 'ref': 'pg_' + s, 'owner': o, 'slug': s, 'mpn': s.upper()})+ done({'status': 'ok'})+ if v == 'export':+ out = a[a.index('-o') + 1]+ with zipfile.ZipFile(out, 'w') as z:+ z.writestr('lib/lib.kicad_sym', st['symbols'])+ z.writestr('lib/lib.pretty/GOOD1.kicad_mod', '(footprint "GOOD1" (model "3dmodels/GOOD1.step"))')+ z.writestr('lib/3dmodels/GOOD1.step', 'x')+ done({'status': 'ok', 'data': {'report': {}}})+done(code=2)+'''+++def script(path, text):+ Path(path).write_text(text); os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)+++class Base(unittest.TestCase):+ def setUp(self):+ self.d = tempfile.mkdtemp(prefix='aiflow-page-test-')+ self.bin = os.path.join(self.d, 'bin'); os.makedirs(self.bin)+ self.state = os.path.join(self.d, 'state.json')+ script(os.path.join(self.bin, 'adom-wiki'), WIKI)+ script(os.path.join(self.bin, 'kicad-cli'), KICAD_CLI)+ env = {'PATH': self.bin + os.pathsep + os.environ['PATH'], 'STUB_STATE': self.state,+ 'ADOM_AIFLOW_KICAD_CLI': os.path.join(self.bin, 'kicad-cli')}+ patcher = mock.patch.dict(os.environ, env); patcher.start(); self.addCleanup(patcher.stop)++ def write_state(self, **kw):+ st = dict(pages=[], libs=[], items=[], calls=[], symbols=HEADER + GOOD + '\n)\n'); st.update(kw)+ json.dump(st, open(self.state, 'w'))++ def read_state(self):+ return json.load(open(self.state))+++class Blame(Base):+ def test_names_the_parts_that_break_the_library(self):+ path = os.path.join(self.d, 'lib.kicad_sym')+ Path(path).write_text(HEADER + GOOD + '\n' + BAD + '\n)\n')+ self.assertEqual(pp.kicad_check(path)[0], 'fail')+ bad = pp.broken_symbols(path, [{'mpn': 'BSC016N06NS', 'wiki': 'adom/bsc016n06ns'}, {'mpn': 'GOOD1', 'wiki': 'adom/good1'}])+ self.assertEqual([b['symbol'] for b in bad], ['bsc016n06ns'])+ self.assertEqual(bad[0]['label'], 'BSC016N06NS (adom/bsc016n06ns)')++ def test_a_child_symbol_is_tested_with_its_parent(self):+ path = os.path.join(self.d, 'lib.kicad_sym')+ Path(path).write_text(HEADER + GOOD + '\n' + CHILD + '\n)\n')+ self.assertEqual(pp.broken_symbols(path, []), [])++ def test_no_kicad_cli_blames_nothing(self):+ path = os.path.join(self.d, 'lib.kicad_sym')+ Path(path).write_text(HEADER + BAD + '\n)\n')+ with mock.patch.dict(os.environ, {'ADOM_AIFLOW_KICAD_CLI': '', 'PATH': self.bin.replace('bin', 'nothing')}):+ self.assertEqual(pp.broken_symbols(path, []), [])+++class Stale(Base):+ def item(self, i, slug):+ return {'id': i, 'ref': 'pg_' + slug, 'owner': 'adom', 'slug': slug, 'mpn': slug.upper()}++ def test_parts_off_the_bom_leave_the_library_and_nothing_else_does(self):+ self.write_state(items=[self.item(1, 'keep'), self.item(2, 'gone'), {'id': 3, 'ref': 'pg_dead', 'resolved': False}])+ removed, _ = pp.drop_stale_items('lib_x', ['adom/keep'])+ self.assertEqual(removed, ['GONE (adom/gone)', 'pg_dead (page gone)'])+ self.assertEqual([i['id'] for i in self.read_state()['items']], [1])++ def test_a_page_named_by_its_id_or_in_other_case_stays(self):+ self.write_state(items=[self.item(1, 'keep'), self.item(2, 'other')])+ self.assertEqual(pp.drop_stale_items('lib_x', ['ADOM/KEEP', 'pg_other'])[0], [])++ def test_an_unreadable_library_removes_nothing(self):+ self.write_state(items=[self.item(1, 'gone')])+ with mock.patch.object(pp, 'run_retrying', return_value=(False, 'boom', [])):+ with self.assertRaises(SystemExit) as e:+ pp.drop_stale_items('lib_x', [])+ self.assertIn('could not read', str(e.exception))+++class Create(Base):+ PARTS = [{'mpn': 'GOOD1', 'wiki': 'adom/good1', 'refs': ['R1'], 'noModelExpected': True}]++ def run_main(self, **over):+ cfg = dict(run=self.d, eda='kicad', page='adom/board1', pageDir=None, title='Board', brief='A board', summary='',+ parts=self.PARTS, create=True, push=True, private=False, replaceReadme=False, existing=False, version='1.0.0', libRef=None)+ cfg.update(over)+ path = os.path.join(self.d, 'config.json'); json.dump(cfg, open(path, 'w'))+ with mock.patch.object(sys, 'argv', ['project-page', path]), mock.patch('builtins.print') as out:+ try:+ pp.main()+ except SystemExit as e:+ return json.loads(str(e))+ return json.loads(out.call_args[0][0])++ def pushed_readme(self):+ return any(c[:2] == ['repo', 'push'] and 'README.md' in c for c in self.read_state()['calls'])++ def test_a_failed_library_check_leaves_no_page_and_names_the_part(self):+ self.write_state(symbols=HEADER + GOOD + '\n' + BAD + '\n)\n')+ r = self.run_main(parts=self.PARTS + [{'mpn': 'BSC016N06NS', 'wiki': 'adom/bsc016n06ns', 'refs': ['Q1']}])+ self.assertFalse(r['ok'])+ self.assertIn('BSC016N06NS (adom/bsc016n06ns): KiCad cannot load this symbol', r['error'])+ self.assertEqual(r['libraryCheck']['brokenSymbols'][0]['page'], 'adom/bsc016n06ns')+ self.assertEqual(self.read_state()['pages'], [], 'nothing was published, and no page was made')+ # fixed, the same --create goes through+ st = self.read_state(); st['symbols'] = HEADER + GOOD + '\n)\n'; json.dump(st, open(self.state, 'w'))+ r = self.run_main()+ self.assertTrue(r['ok'], r); self.assertTrue(r['created']); self.assertTrue(self.pushed_readme())++ def test_a_failed_push_can_be_rerun_with_create_and_finishes_the_page(self):+ self.write_state(pushFails=True)+ self.assertFalse(self.run_main()['ok'])+ self.assertEqual(self.read_state()['pages'], ['adom/board1'])+ st = self.read_state(); st['pushFails'] = False; st['calls'] = []; json.dump(st, open(self.state, 'w'))+ r = self.run_main()+ self.assertTrue(r['ok'], r); self.assertTrue(r['created']); self.assertTrue(self.pushed_readme())+ self.assertFalse(any(c[:2] == ['page', 'create'] for c in self.read_state()['calls']))++ def test_a_finished_page_or_someone_elses_is_still_refused(self):+ self.write_state()+ self.assertTrue(self.run_main()['ok'])+ r = self.run_main()+ self.assertFalse(r['ok']); self.assertIn('already exists', r['error'])+ self.write_state(pages=['adom/board2'])+ r = self.run_main(page='adom/board2')+ self.assertFalse(r['ok']); self.assertIn('already exists', r['error'])++ def test_a_rerun_drops_what_left_the_bom_before_exporting(self):+ self.write_state(items=[{'id': 9, 'ref': 'pg_old', 'owner': 'adom', 'slug': 'old', 'mpn': 'OLD'}])+ r = self.run_main()+ self.assertTrue(r['ok'], r)+ self.assertEqual(r['adds']['removedStale'], ['OLD (adom/old)'])+ self.assertTrue(any('no longer on the BOM' in n for n in r['adds']['notes']))+ calls = self.read_state()['calls']+ order = [c[1] for c in calls if c[0] == 'library' and c[1] in ('remove', 'add', 'export')]+ self.assertEqual(order, ['remove', 'add', 'export'])+++if __name__ == '__main__':+ unittest.main()