master
Colby Knox Merge PR #339: kicad-lane: 1.0.81, engine 0.12.2, assembly fixes (#24) 8f84613 12d ago
//! The EDA Bridge Standard's bom, assembly_check and jlcpcb_order, for KiCad. The BOM's grouping,
//! the parameter names matched across libraries, the files written and JLCPCB's two assembly
//! tiers are ported from the Altium bridge (bridge-rs/src/bom.rs and assembly.rs, 0.2.77), so a
//! KiCad BOM and an Altium BOM read the same. Pure: the verbs read the netlist and the board.
//!
//! A KiCad part is its schematic symbol (the netlist's fields, `dnp` and `exclude_from_bom`
//! flags) merged with its board footprint (footprint, placement, the board's own fields and
//! attributes). Without a schematic the board's footprints are the parts.

use std::collections::{BTreeMap, HashMap};

use serde_json::{json, Map, Value};

use crate::fab;
use crate::netlist::Netlist;

pub const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "Mfr. Part #", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Manufacturer_Part_Number", "Part Number", "PartNumber", "Part_Number"];
pub const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Manufacturer_Name", "Mfr", "MFR", "MANUFACTURER"];
pub const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];
pub const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];
pub const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "LCSC#", "JLCPCB Part #", "JLC Part #", "JLCPCB Part", "JLCPCB", "JLC"];
const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];
const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];
const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];

fn param(params: &[(String, String)], names: &[&str]) -> String {
    for n in names {
        if let Some((_, v)) = params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) {
            return v.trim().to_string();
        }
    }
    String::new()
}

fn truthy(v: &str) -> bool {
    matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x")
}

/// R1 < R2 < R10 < R10A.
pub fn natural_key(d: &str) -> (String, u64, String) {
    fab::ref_key(&d.to_ascii_uppercase())
}

/// One placed part: its symbol's fields and its footprint's placement.
#[derive(Debug, Clone, Default)]
pub struct Part {
    pub designator: String,
    pub value: String,
    pub description: String,
    pub lib_ref: String,
    pub params: Vec<(String, String)>,
    pub footprint: String,
    /// (x, y in the standard's frame, rotation, "top" | "bottom")
    pub placed: Option<(f64, f64, f64, String)>,
    pub dnp: bool,
    /// exclude_from_bom (schematic or board) or board_only: not a bought part
    pub excluded: bool,
}

impl Part {
    pub fn get(&self, names: &[&str]) -> String {
        param(&self.params, names)
    }
    pub fn mpn(&self) -> String {
        self.get(MPN)
    }
    /// Test pads, fiducials and logos carry an MPN or manufacturer that says nothing is bought.
    pub fn not_purchased(&self) -> bool {
        [self.mpn(), self.get(MFR)].iter().any(|v| {
            let m = v.trim().to_ascii_lowercase();
            m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")
        })
    }
}

/// The parts, from the schematic's netlist merged with the board's footprints (`placed` by
/// reference, from the board model), or the board alone. Returns the parts and which designators
/// are only on the sheets or only on the board.
pub fn parts(nl: Option<&Netlist>, board: &[fab::Part], placed: &HashMap<String, (f64, f64, f64, String)>) -> (Vec<Part>, Value) {
    let on_board: HashMap<&str, &fab::Part> = board.iter().filter(|b| !b.reference.is_empty()).map(|b| (b.reference.as_str(), b)).collect();
    let board_part = |b: &fab::Part| -> Part {
        let params: Vec<(String, String)> = b.props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
        let dnp = b.dnp() || DNP.iter().any(|n| truthy(&param(&params, &[n])));
        Part { designator: b.reference.clone(), value: b.value.clone(), description: param(&params, DESCRIPTION), lib_ref: String::new(), footprint: b.footprint.clone(),
            placed: placed.get(&b.reference).cloned(), dnp, excluded: !b.in_bom(), params }
    };
    let Some(nl) = nl else {
        let mut out: Vec<Part> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#')).map(board_part).collect();
        out.sort_by_key(|p| natural_key(&p.designator));
        return (out, json!({"sheetNotOnBoard": [], "boardNotOnSheet": [], "from": "board"}));
    };
    let mut out = Vec::new();
    let mut sheet_only = Vec::new();
    for c in &nl.components {
        if c.reference.starts_with('#') {
            continue;
        }
        let flag = |k: &str| c.properties.iter().any(|(n, _)| n.eq_ignore_ascii_case(k));
        let mut params: Vec<(String, String)> = c.fields.clone();
        params.push(("Value".into(), c.value.clone()));
        let b = on_board.get(c.reference.as_str());
        if let Some(b) = b {
            // the board's fields fill what the sheet left empty
            for (k, v) in &b.props {
                if param(&params, &[k]).is_empty() {
                    params.push((k.clone(), v.clone()));
                }
            }
        } else if !flag("exclude_from_board") {
            sheet_only.push(json!(c.reference));
        }
        let dnp = flag("dnp") || b.map(|b| b.dnp()).unwrap_or(false) || DNP.iter().any(|n| truthy(&param(&params, &[n])));
        let excluded = flag("exclude_from_bom") || b.map(|b| !b.in_bom()).unwrap_or(false);
        out.push(Part {
            designator: c.reference.clone(), value: c.value.clone(), description: c.description.clone(), lib_ref: c.libsource.clone(),
            footprint: b.map(|b| b.footprint.clone()).filter(|f| !f.is_empty()).unwrap_or_else(|| c.footprint.clone()),
            placed: placed.get(&c.reference).cloned(), dnp, excluded, params,
        });
    }
    let on_sheet: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
    let mut board_only: Vec<String> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#') && !on_sheet.contains(&b.reference.as_str()) && b.in_bom())
        .map(|b| b.reference.clone()).collect();
    board_only.sort_by_key(|d| natural_key(d));
    out.sort_by_key(|p| natural_key(&p.designator));
    (out, json!({"sheetNotOnBoard": sheet_only, "boardNotOnSheet": board_only, "from": "schematic"}))
}

const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];

/// The footprint's name without its library (`Capacitor_SMD:C_0402_1005Metric` is `C_0402_1005Metric`).
fn fp_name(f: &str) -> &str {
    f.rsplit(':').next().unwrap_or(f)
}

/// Group the fitted parts: one line per manufacturer part number, else per value and footprint.
/// Returns (lines, checks).
pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {
    let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();
    for p in parts.iter().filter(|p| !p.dnp && !p.excluded) {
        let m = p.mpn();
        let key = if m.is_empty() { format!("~{}|{}", p.value.to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };
        groups.entry(key).or_default().push(p);
    }
    let mut lines = Vec::new();
    let mut inconsistent = Vec::new();
    for (_, mut g) in groups {
        g.sort_by_key(|p| natural_key(&p.designator));
        let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();
        let mut line = Map::new();
        line.insert("quantity".into(), json!(g.len()));
        line.insert("designators".into(), json!(refs));
        for (name, names) in FIELDS {
            let vals: Vec<String> = g.iter().map(|p| p.get(names)).collect();
            let mut distinct: Vec<&String> = vals.iter().filter(|v| !v.is_empty()).collect();
            distinct.sort();
            distinct.dedup();
            if distinct.len() > 1 {
                inconsistent.push(json!({"field": name, "designators": refs, "values": distinct}));
            }
            line.insert((*name).into(), json!(distinct.first().map(|s| s.as_str()).unwrap_or("")));
        }
        let first = g[0];
        let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();
        fps.sort();
        fps.dedup();
        if fps.len() > 1 {
            inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps}));
        }
        let fp = fps.first().copied().unwrap_or("");
        let package = first.get(PACKAGE);
        line.insert("value".into(), json!(first.value));
        line.insert("comment".into(), json!(first.value));
        line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));
        line.insert("footprint".into(), json!(fp));
        line.insert("package".into(), json!(if package.is_empty() { fp_name(fp).to_string() } else { package }));
        line.insert("libReference".into(), json!(first.lib_ref));
        line.insert("purchase".into(), json!(!first.not_purchased()));
        lines.push(Value::Object(line));
    }
    lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));
    let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.excluded && !p.not_purchased()).collect();
    let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };
    let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());
    let dnp = sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect());
    let excluded = sorted(parts.iter().filter(|p| !p.dnp && p.excluded).map(|p| p.designator.clone()).collect());
    let not_purchased = sorted(parts.iter().filter(|p| !p.dnp && !p.excluded && p.not_purchased()).map(|p| p.designator.clone()).collect());
    // JLCPCB places only what it can source by LCSC number: every other purchased line is consigned or blocks
    let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && l["lcsc"].as_str().unwrap_or("").is_empty())
        .map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();
    let checks = json!({
        "missingMpn": missing(MPN), "missingManufacturer": missing(MFR),
        "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},
        "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded, "notPurchased": not_purchased,
        "jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},
    });
    (lines, checks)
}

fn csv_cell(s: &str) -> String {
    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}

fn csv(rows: &[Vec<String>]) -> String {
    rows.iter().map(|r| r.iter().map(|c| csv_cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"
}

fn st(v: &Value) -> String {
    v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())
}

/// The files, as (name, text): bom.csv, JLCPCB's BOM and CPL, the Mouser and DigiKey BOM-tool
/// files. `boards` multiplies the quantities of the ordering files.
pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {
    let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();
    let mut out = Vec::new();
    let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];
    for (i, l) in lines.iter().enumerate() {
        rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);
    }
    out.push(("bom.csv".to_string(), csv(&rows)));
    // JLCPCB assembly: their BOM and CPL columns; positions as KiCad's own position file gives them (mm, y up)
    let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];
    for l in lines.iter().filter(|l| l["purchase"] != json!(false)) {
        j.push(vec![st(&l["value"]), refs(l), fp_name(&st(&l["footprint"])).to_string(), st(&l["lcsc"])]);
    }
    out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));
    let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];
    let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.excluded && !p.not_purchased() && p.placed.is_some()).collect();
    placed.sort_by_key(|p| natural_key(&p.designator));
    for p in placed {
        let (x, y, r, side) = p.placed.clone().unwrap();
        c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);
    }
    out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));
    for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {
        let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];
        for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) {
            m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);
        }
        out.push((name.to_string(), csv(&m)));
    }
    out
}

// ---------------------------------------------------------------------------
// assembly_check: JLCPCB's PCBA tiers (jlcpcb.com/capabilities/pcb-assembly-capabilities, read 2026-10-26)
// ---------------------------------------------------------------------------

fn f(v: &Value) -> f64 {
    v.as_f64().unwrap_or(0.0)
}

fn r3(v: f64) -> f64 {
    (v * 1000.0).round() / 1000.0
}

/// What the board offers the assembly line, from the board model. Pure.
pub fn facts(st: &Value) -> Value {
    let pads = st["pads"].as_array().cloned().unwrap_or_default();
    // a component with no pads (a logo, a graphic) is not a part the line places (std #24)
    let with_pads: std::collections::BTreeSet<&str> = pads.iter().filter_map(|p| p["ref"].as_str()).collect();
    let comps: Vec<Value> = st["components"].as_array().cloned().unwrap_or_default().into_iter().filter(|c| c["ref"].as_str().map(|r| with_pads.contains(r)).unwrap_or(false)).collect();
    let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();
    for p in pads.iter().filter(|p| f(&p["drill"]) == 0.0) {
        if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) {
            by.entry(r.to_string()).or_default().push((f(&p["x"]), f(&p["y"])));
        }
    }
    // the finest pitch: the closest pad centres within one part with 3 or more SMD pads;
    // the smallest two-pad part: its pad centres (0402 about 1.0 mm, 0201 0.5, 01005 0.35)
    let mut pitch: Option<(f64, String)> = None;
    let mut chip: Option<(f64, String)> = None;
    for (r, ps) in &by {
        let mut d = f64::MAX;
        for i in 0..ps.len() {
            for j in i + 1..ps.len() {
                // pads sharing one centre are one land drawn in pieces, not a 0 mm pitch (std #24)
                let dd = ((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt();
                if dd >= 0.01 {
                    d = d.min(dd);
                }
            }
        }
        if d == f64::MAX {
            continue;
        }
        if ps.len() >= 3 && pitch.as_ref().map(|p| d < p.0).unwrap_or(true) {
            pitch = Some((d, r.clone()));
        }
        if ps.len() == 2 && chip.as_ref().map(|p| d < p.0).unwrap_or(true) {
            chip = Some((d, r.clone()));
        }
    }
    let named = |c: &Value, pat: &[&str]| { let p = c["pattern"].as_str().or(c["footprint"].as_str()).unwrap_or("").to_ascii_uppercase(); pat.iter().any(|x| p.contains(x)) };
    let tiny: Vec<Value> = comps.iter().filter(|c| named(c, &["0201", "01005", "0603M", "0402M"])).map(|c| json!({"ref": c["ref"], "footprint": c["footprint"]})).collect();
    let fid = |s: &str| s.to_ascii_uppercase().starts_with("FID");
    let fiducials = comps.iter().filter(|c| c["ref"].as_str().map(fid).unwrap_or(false) || named(c, &["FIDUCIAL"])).count();
    let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom").map(|c| c["ref"].clone()).collect();
    let tht: std::collections::BTreeSet<String> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| !r.is_empty()).unwrap_or(false))
        .filter_map(|p| p["ref"].as_str().map(String::from)).collect();
    json!({"parts": comps.len(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),
           "smallestChip": chip.map(|(d, r)| json!({"padCentresMm": r3(d), "ref": r})), "tinyFootprints": tiny, "fiducials": fiducials,
           "throughHoleParts": tht.into_iter().collect::<Vec<_>>()})
}

fn rule(name: &str, ok: Option<bool>, detail: String) -> Value {
    json!({"rule": name, "ok": ok, "detail": detail})
}

/// The board against one tier ("economic" or "standard"). `size` is [w, h] mm (the board, or the
/// panel it ships in), `lcsc_missing` the purchased lines with no LCSC number (None: not checked).
pub fn tier(name: &str, fa: &Value, size: (f64, f64), thick: Option<f64>, layers: Option<i64>, lcsc_missing: Option<usize>, panel: bool) -> Value {
    let eco = name == "economic";
    let mut rs = Vec::new();
    let (w, h) = (size.0.min(size.1), size.0.max(size.1));
    let (lo, hi): ((f64, f64), (f64, f64)) = match (eco, panel) {
        (true, false) => ((10.0, 10.0), (470.0, 500.0)),
        (true, true) => ((10.0, 10.0), (250.0, 250.0)),
        (false, false) => ((70.0, 70.0), (460.0, 500.0)),
        (false, true) => ((70.0, 70.0), (250.0, 250.0)),
    };
    let ok = w >= lo.0 && h >= lo.1 && w <= hi.0.min(hi.1) && h <= hi.0.max(hi.1);
    rs.push(rule(&format!("{} {} x {} to {} x {} mm", if panel { "panel" } else { "board" }, lo.0, lo.1, hi.0, hi.1), Some(ok),
        format!("{} x {} mm{}", r3(size.0), r3(size.1), if !ok && !eco && !panel { ": a panel (kicad_panelize) can bring it to 70 x 70" } else { "" })));
    if eco {
        rs.push(match thick { Some(t) => rule("board 0.8 to 1.6 mm thick", Some((0.8 - 1e-6..=1.6 + 1e-6).contains(&t)), format!("{} mm", r3(t))), None => rule("board 0.8 to 1.6 mm thick", None, "thickness not read".into()) });
        rs.push(match layers { Some(n) => rule("2, 4 or 6 layers", Some([2, 4, 6].contains(&n)), format!("{n} layers")), None => rule("2, 4 or 6 layers", None, "layers not read".into()) });
        let bot = fa["bottomSideParts"].as_array().map(|a| a.len()).unwrap_or(0);
        rs.push(rule("parts on one side only", Some(bot == 0), if bot == 0 { "all on top".into() } else { format!("{bot} part(s) on the bottom: {}", fa["bottomSideParts"]) }));
    }
    let (chip_min, pitch_min) = if eco { (0.8, 0.4) } else { (0.3, 0.35) };
    let tiny = fa["tinyFootprints"].as_array().map(|a| a.len()).unwrap_or(0);
    let chip = fa["smallestChip"]["padCentresMm"].as_f64();
    let chip_ok = (eco && tiny == 0 || !eco) && chip.map(|c| c >= chip_min).unwrap_or(true);
    rs.push(rule(if eco { "smallest package 0402" } else { "smallest package 01005" }, Some(chip_ok),
        format!("smallest two-pad part {} (pad centres {} mm){}", fa["smallestChip"]["ref"].as_str().unwrap_or("-"), chip.map(r3).unwrap_or(0.0), if tiny > 0 { format!("; footprints named under 0402: {}", fa["tinyFootprints"]) } else { String::new() })));
    let pitch = fa["finestPitch"]["mm"].as_f64();
    rs.push(rule(&format!("IC pin pitch {pitch_min} mm or more (BGA {} mm)", if eco { 0.5 } else { 0.3 }), Some(pitch.map(|p| p + 1e-6 >= pitch_min).unwrap_or(true)),
        format!("finest {} mm on {}", pitch.map(r3).unwrap_or(0.0), fa["finestPitch"]["ref"].as_str().unwrap_or("-"))));
    if !eco {
        let n = fa["fiducials"].as_u64().unwrap_or(0);
        rs.push(rule("fiducials and edge rails", Some(n >= 2 || panel), if panel { "on the panel rails (kicad_panelize adds three)".into() } else { format!("{n} fiducial(s) on the board: add 3 or ship it in a panel with rails (kicad_panelize)") }));
    }
    rs.push(match lcsc_missing {
        Some(0) => rule("every purchased part has an LCSC number", Some(true), "all lines".into()),
        Some(n) => rule("every purchased part has an LCSC number", Some(false), format!("{n} line(s) without one: find them (adom-parts-search, adom-jlcpcb) or consign them")),
        None => rule("every purchased part has an LCSC number", None, "not checked: no schematic or BOM read".into()),
    });
    let ok = rs.iter().all(|r| r["ok"] != json!(false));
    json!({"tier": name, "ok": ok, "rules": rs})
}

#[cfg(test)]
mod tests {
    use super::*;

    fn part(d: &str, value: &str, fp: &str, params: &[(&str, &str)]) -> Part {
        Part { designator: d.into(), value: value.into(), footprint: fp.into(), params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
            placed: Some((1.0, -2.0, 90.0, "top".into())), ..Default::default() }
    }

    #[test]
    fn lines_group_by_mpn_and_say_what_an_order_lacks() {
        let ps = vec![
            part("C2", "100n", "C:C_0402", &[("MPN", "GRM155"), ("LCSC", "C1525"), ("Manufacturer", "Murata")]),
            part("C10", "100n", "C:C_0402", &[("Manufacturer Part Number", "GRM155"), ("LCSC Part #", "C1525"), ("Manufacturer", "Murata")]),
            part("R1", "10k", "R:R_0402", &[]),
            Part { dnp: true, ..part("R2", "10k", "R:R_0402", &[]) },
            Part { excluded: true, ..part("H1", "MountingHole", "MH:M3", &[]) },
            part("TP1", "TP", "TP:Pad", &[("MPN", "none (PCB copper)")]),
        ];
        let (lines, checks) = group(&ps);
        let c = lines.iter().find(|l| l["mpn"] == "GRM155").unwrap();
        assert_eq!(c["designators"], json!(["C2", "C10"]));
        assert_eq!((c["quantity"].as_u64(), c["lcsc"].as_str(), c["package"].as_str()), (Some(2), Some("C1525"), Some("C_0402")));
        assert_eq!(checks["missingMpn"], json!(["R1"]));
        assert_eq!(checks["dnp"], json!(["R2"]));
        assert_eq!(checks["excludedKinds"], json!(["H1"]));
        assert_eq!(checks["notPurchased"], json!(["TP1"]));
        assert_eq!(checks["jlcpcb"]["linesWithoutLcsc"][0]["designators"], json!(["R1"]));
        let fs = files(&lines, &ps, 10);
        let cpl = &fs.iter().find(|f| f.0 == "cpl-jlcpcb.csv").unwrap().1;
        assert!(cpl.contains("C2,1.0000mm,-2.0000mm,Top,90") && !cpl.contains("R2,") && !cpl.contains("TP1,"), "{cpl}");
        let mouser = &fs.iter().find(|f| f.0 == "bom-mouser.csv").unwrap().1;
        assert!(mouser.contains(",GRM155,Murata,20,"), "{mouser}");
    }

    #[test]
    fn shared_pad_centres_and_padless_parts_do_not_fail_assembly() {
        // std #24: a FET whose drain pads are one land drawn as several pads with one centre, and a logo on the bottom
        let st = json!({"components": [{"ref": "Q1", "side": "top"}, {"ref": "LOGO1", "side": "bottom"}],
            "pads": [{"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0},
                     {"ref": "Q1", "x": 0.65, "y": 1.5, "drill": 0.0}, {"ref": "Q1", "x": 1.3, "y": 1.5, "drill": 0.0}]});
        let fa = facts(&st);
        assert_eq!(fa["finestPitch"], json!({"mm": 0.65, "ref": "Q1"}), "{fa}");
        assert_eq!(fa["parts"], 1);
        assert_eq!(fa["bottomSideParts"], json!([]));
    }

    #[test]
    fn a_small_two_sided_board_needs_standard_in_a_panel() {
        let st = json!({"components": [{"ref": "U1", "side": "top", "pattern": "QFN-20"}, {"ref": "C1", "side": "top", "pattern": "C_0402"}, {"ref": "R9", "side": "bottom", "pattern": "R_0603"}],
            "pads": [{"ref": "U1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 0.5, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 1.0, "y": 0.0, "drill": 0.0},
                     {"ref": "C1", "x": 5.0, "y": 0.0, "drill": 0.0}, {"ref": "C1", "x": 6.0, "y": 0.0, "drill": 0.0},
                     {"ref": "R9", "x": 9.0, "y": 0.0, "drill": 0.0}, {"ref": "R9", "x": 10.5, "y": 0.0, "drill": 0.0}]});
        let fa = facts(&st);
        assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));
        assert_eq!(fa["bottomSideParts"], json!(["R9"]));
        assert_eq!(tier("economic", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);
        assert_eq!(tier("standard", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);
        assert_eq!(tier("standard", &fa, (90.0, 80.0), Some(1.6), Some(2), Some(0), true)["ok"], true);
    }

    #[test]
    fn sheet_fields_merge_with_the_board() {
        let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>C:C_0402</footprint><fields><field name="MPN">GRM155</field></fields></comp>
            <comp ref="R5"><value>1k</value><property name="dnp"/></comp></components><nets/></export>"#;
        let nl = crate::netlist::parse_kicadxml(xml).unwrap();
        let mut props = BTreeMap::new();
        props.insert("Reference".to_string(), "C1".to_string());
        props.insert("LCSC".to_string(), "C1525".to_string());
        let board = vec![fab::Part { reference: "C1".into(), value: "100n".into(), footprint: "Capacitor_SMD:C_0402_1005Metric".into(), props, ..Default::default() },
                         fab::Part { reference: "J9".into(), value: "CONN".into(), ..Default::default() }];
        let mut placed = HashMap::new();
        placed.insert("C1".to_string(), (3.0, -4.0, 0.0, "top".to_string()));
        let (ps, cross) = parts(Some(&nl), &board, &placed);
        let c1 = ps.iter().find(|p| p.designator == "C1").unwrap();
        assert_eq!((c1.mpn().as_str(), c1.get(LCSC).as_str(), c1.footprint.as_str()), ("GRM155", "C1525", "Capacitor_SMD:C_0402_1005Metric"));
        assert!(ps.iter().find(|p| p.designator == "R5").unwrap().dnp);
        assert_eq!(cross["sheetNotOnBoard"], json!(["R5"]));
        assert_eq!(cross["boardNotOnSheet"], json!(["J9"]));
    }
}