main
John Lauer Publish 0.2.15 bb17f84
//! Landing bookkeeping that needs no desktop: which traces of a plan are already on the live
//! board (so a rerun after a dropped reply never lands a trace twice), which planned vias would
//! touch another net's copper, which landed vias came back on another net, and which failed
//! replies are the transport rather than KiCad.
use std::collections::BTreeMap;

use aiflow_board::geom;
use serde_json::{json, Value};

pub type Pt = (f64, f64);

/// Two plan points closer than this are the same point on the live board.
pub const SAME_POINT_MM: f64 = 0.05;

#[derive(Clone, Debug)]
pub struct LiveSeg { pub net: String, pub a: Pt, pub b: Pt, pub width: f64, pub uuid: String }

#[derive(Clone, Debug)]
pub struct LiveVia { pub net: String, pub at: Pt, pub size: f64, pub drill: f64, pub uuid: String }

/// The copper a kicad_routing_state reply describes, plus the pads by name (J5.1) with net and centre.
#[derive(Clone, Debug, Default)]
pub struct LiveCopper {
    pub segments: Vec<LiveSeg>,
    pub vias: Vec<LiveVia>,
    pub pads: BTreeMap<String, (String, Pt)>,
}

fn xy(v: &Value) -> Option<Pt> {
    Some((v["x"].as_f64()?, v["y"].as_f64()?))
}

impl LiveCopper {
    pub fn from_state(state: &Value) -> LiveCopper {
        let mut live = LiveCopper::default();
        for s in state["segments"].as_array().into_iter().flatten() {
            let (Some(a), Some(b)) = (xy(&s["start"]), xy(&s["end"])) else { continue };
            live.segments.push(LiveSeg { net: s["net_name"].as_str().unwrap_or("").to_string(), a, b, width: s["width"].as_f64().unwrap_or(0.0), uuid: s["uuid"].as_str().unwrap_or("").to_string() });
        }
        for v in state["vias"].as_array().into_iter().flatten() {
            let Some(at) = xy(&v["position"]) else { continue };
            let size = v["size"].as_f64().unwrap_or(0.0);
            // a reply without the drill: half the copper, the usual via ring
            live.vias.push(LiveVia { net: v["net_name"].as_str().unwrap_or("").to_string(), at, size, drill: v["drill"].as_f64().unwrap_or(size / 2.0), uuid: v["uuid"].as_str().unwrap_or("").to_string() });
        }
        for p in state["pads"].as_array().into_iter().flatten() {
            let (Some(name), Some(at)) = (p["name"].as_str(), xy(p)) else { continue };
            let net = p["netName"].as_str().or(p["netKey"].as_str()).unwrap_or("").to_string();
            live.pads.insert(name.to_string(), (net, at));
        }
        live
    }
}

/// One plan trace as copper: its net, the segments it draws and the vias its layer markers make.
#[derive(Clone, Debug, PartialEq)]
pub struct Trace { pub net: String, pub segs: Vec<(Pt, Pt)>, pub vias: Vec<Pt>, pub width: f64, pub via_size: f64 }

/// An entry with several paths becomes one entry per path: one kicad_route_net per trace.
pub fn expand_traces(entries: &[Value]) -> Vec<Value> {
    entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {
        Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<Value>>(),
        _ => vec![e.clone()],
    }).collect()
}

/// A trace entry (one path) resolved against the live pads, in the bridge's waypoint grammar:
/// "J1.1", [x, y], {x, y, layer} or {pad, layer}; a layer marker that changes the layer after the
/// first waypoint is a through via at that point. None when a pad is not on the board.
pub fn resolve(entry: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Option<Trace> {
    let path = entry.get("paths").and_then(|p| p.as_array()).and_then(|p| p.first()).or_else(|| entry.get("points"))?.as_array()?;
    let mut layer = entry["layer"].as_str().unwrap_or("F.Cu").to_string();
    let mut pts: Vec<Pt> = Vec::new();
    let mut vias: Vec<Pt> = Vec::new();
    for (i, wp) in path.iter().enumerate() {
        let (p, marker) = if let Some(name) = wp.as_str() {
            (pads.get(name)?.1, None)
        } else if let Some(a) = wp.as_array() {
            ((a.first()?.as_f64()?, a.get(1)?.as_f64()?), None)
        } else if let Some(pad) = wp["pad"].as_str() {
            (pads.get(pad)?.1, wp["layer"].as_str())
        } else {
            (xy(wp)?, wp["layer"].as_str())
        };
        if let Some(l) = marker {
            if i > 0 && l != layer { vias.push(p); }
            layer = l.to_string();
        }
        pts.push(p);
    }
    let segs = pts.windows(2).filter(|w| (w[0].0 - w[1].0).hypot(w[0].1 - w[1].1) > 1e-6).map(|w| (w[0], w[1])).collect();
    Some(Trace { net: entry["net"].as_str().unwrap_or("").to_string(), segs, vias, width: entry["width"].as_f64().unwrap_or(0.25), via_size: entry["viaSize"].as_f64().unwrap_or(0.6) })
}

fn same(a: Pt, b: Pt) -> bool {
    (a.0 - b.0).abs() < SAME_POINT_MM && (a.1 - b.1).abs() < SAME_POINT_MM
}

/// Every segment and via of the trace is already on the live board on the same net, at the same
/// points within SAME_POINT_MM (a segment may run either way). A trace with no copper is never present.
pub fn present(t: &Trace, live: &LiveCopper) -> bool {
    if t.segs.is_empty() && t.vias.is_empty() { return false; }
    // as wide as planned too: a net widened after an analysis is not "already there" because
    // its old, narrower track runs along the same points
    t.segs.iter().all(|(a, b)| live.segments.iter().any(|s| s.net == t.net && s.width + 1e-4 >= t.width && ((same(s.a, *a) && same(s.b, *b)) || (same(s.a, *b) && same(s.b, *a)))))
        && t.vias.iter().all(|v| live.vias.iter().any(|lv| lv.net == t.net && same(lv.at, *v)))
}

/// Copper a planned via must not touch unless it is on the same net.
#[derive(Clone, Debug)]
pub enum Copper {
    Seg { a: Pt, b: Pt, half: f64, net: String, what: String },
    Disc { at: Pt, r: f64, net: String, what: String },
    Poly { poly: Vec<Pt>, net: String, what: String },
}

impl Copper {
    fn net(&self) -> &str {
        match self { Copper::Seg { net, .. } | Copper::Disc { net, .. } | Copper::Poly { net, .. } => net }
    }
    fn what(&self) -> &str {
        match self { Copper::Seg { what, .. } | Copper::Disc { what, .. } | Copper::Poly { what, .. } => what }
    }
    fn touches(&self, at: Pt, r: f64) -> bool {
        let eps = 1e-4;
        match self {
            Copper::Seg { a, b, half, .. } => geom::seg_distance(at.0, at.1, a.0, a.1, b.0, b.1) < r + half - eps,
            Copper::Disc { at: c, r: rc, .. } => (at.0 - c.0).hypot(at.1 - c.1) < r + rc - eps,
            Copper::Poly { poly, .. } => poly.len() > 2 && (geom::point_in_poly(at.0, at.1, poly) || geom::point_poly_distance(at.0, at.1, poly) < r - eps),
        }
    }
}

/// The live board's tracks and vias as copper.
pub fn live_copper(live: &LiveCopper) -> Vec<Copper> {
    let mut out: Vec<Copper> = live.segments.iter().map(|s| Copper::Seg { a: s.a, b: s.b, half: s.width / 2.0, net: s.net.clone(), what: format!("live {} track", s.net) }).collect();
    out.extend(live.vias.iter().map(|v| Copper::Disc { at: v.at, r: v.size / 2.0, net: v.net.clone(), what: format!("live {} via", v.net) }));
    out
}

/// A routing plan's copper (tracks and the vias of its layer markers), pads resolved on the live board.
pub fn plan_copper(plan: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Vec<Copper> {
    let entries = plan["nets"].as_array().cloned().unwrap_or_default();
    let mut out = Vec::new();
    for e in expand_traces(&entries) {
        let Some(t) = resolve(&e, pads) else { continue };
        out.extend(t.segs.iter().map(|(a, b)| Copper::Seg { a: *a, b: *b, half: t.width / 2.0, net: t.net.clone(), what: format!("planned {} track", t.net) }));
        out.extend(t.vias.iter().map(|v| Copper::Disc { at: *v, r: t.via_size / 2.0, net: t.net.clone(), what: format!("planned {} via", t.net) }));
    }
    out
}

/// The first copper of another net that a via of `net` at `at` with radius `r` would touch.
/// Touching copper is what makes KiCad hand the via that other net.
pub fn via_touch(net: &str, at: Pt, r: f64, copper: &[Copper]) -> Option<String> {
    copper.iter().find(|c| c.net() != net && c.touches(at, r)).map(|c| c.what().to_string())
}

/// A drilled hole a planned via must keep the hole-to-hole minimum from, and (a via) the copper
/// clearance too, whatever its net: a live or planned via, a through-hole pad, a bare drill.
#[derive(Clone, Debug)]
pub struct Hole { pub at: Pt, pub size: f64, pub drill: f64, pub net: String, pub via: bool, pub what: String }

/// The live board's vias as holes.
pub fn live_holes(live: &LiveCopper) -> Vec<Hole> {
    live.vias.iter().map(|v| Hole { at: v.at, size: v.size, drill: v.drill, net: v.net.clone(), via: true, what: format!("live {} via at ({:.2}, {:.2})", v.net, v.at.0, v.at.1) }).collect()
}

/// A board's through-hole pads and bare drills as holes (vias come from the live board instead).
pub fn board_holes(board: &aiflow_board::Board) -> Vec<Hole> {
    let mut out: Vec<Hole> = board.pads.iter().filter(|p| p.thru).filter_map(|p| Some(Hole { at: (p.x, p.y), size: 2.0 * p.half_long, drill: p.drill?, net: p.net.clone(), via: false, what: format!("pad {} hole", p.key) })).collect();
    out.extend(board.holes.iter().map(|h| Hole { at: (h.x, h.y), size: h.diameter(), drill: h.diameter(), net: String::new(), via: false, what: format!("drilled hole {}{}", h.reference, if h.name.is_empty() { String::new() } else { format!(".{}", h.name) }) }));
    out
}

/// A routing plan's vias as holes, pads resolved on the live board.
pub fn plan_holes(plan: &Value, pads: &BTreeMap<String, (String, Pt)>) -> Vec<Hole> {
    let entries = plan["nets"].as_array().cloned().unwrap_or_default();
    let mut out = Vec::new();
    for e in expand_traces(&entries) {
        let Some(t) = resolve(&e, pads) else { continue };
        let drill = e["viaDrill"].as_f64().unwrap_or(t.via_size / 2.0);
        out.extend(t.vias.iter().map(|v| Hole { at: *v, size: t.via_size, drill, net: t.net.clone(), via: true, what: format!("planned {} via at ({:.2}, {:.2})", t.net, v.0, v.1) }));
    }
    out
}

/// The first hole a planned via of `net` at `at` (copper `size`, `drill`) clashes with: drill edge
/// to drill edge under `h2h`, or, for another via of any net, copper edge to copper edge under
/// `clearance` (a same-net via on top of another is still a drill clash; issue #83). The same via
/// (same net, same point) is not a clash with itself.
pub fn via_hole_clash(net: &str, at: Pt, size: f64, drill: f64, holes: &[Hole], h2h: f64, clearance: f64) -> Option<String> {
    let eps = 1e-4;
    holes.iter().find_map(|h| {
        let d = (h.at.0 - at.0).hypot(h.at.1 - at.1);
        if h.net == net && same(h.at, at) { return None; }
        let drills = (drill + h.drill) / 2.0 + h2h;
        if d + eps < drills {
            return Some(format!("{}: centres {d:.2} mm apart, under the {:.2} mm hole-to-hole minimum ({h2h} mm drill edge to drill edge)", h.what, drills));
        }
        let copper = (size + h.size) / 2.0 + clearance;
        if h.via && d + eps < copper {
            return Some(format!("{}: centres {d:.2} mm apart, under the {:.2} mm via-to-via spacing ({clearance} mm copper clearance)", h.what, copper));
        }
        None
    })
}

/// A landed via that came back on a net other than the planned one.
#[derive(Clone, Debug, PartialEq)]
pub struct ViaMismatch { pub trace: usize, pub planned: String, pub actual: String, pub at: Pt, pub uuid: String }

/// Read back each landed via by position: (trace number, trace) pairs against the live copper.
/// A planned via with no live via at its place is not reported here (the bridge's DRC and the
/// validate step own missing copper).
pub fn via_net_mismatches(landed: &[(usize, Trace)], live: &LiveCopper) -> Vec<ViaMismatch> {
    let mut out = Vec::new();
    for (n, t) in landed {
        for v in &t.vias {
            let near = live.vias.iter().filter(|lv| same(lv.at, *v)).min_by(|a, b| {
                let da = (a.at.0 - v.0).hypot(a.at.1 - v.1);
                let db = (b.at.0 - v.0).hypot(b.at.1 - v.1);
                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
            });
            if let Some(lv) = near {
                if lv.net != t.net && !live.vias.iter().any(|o| o.net == t.net && same(o.at, *v)) {
                    out.push(ViaMismatch { trace: *n, planned: t.net.clone(), actual: lv.net.clone(), at: *v, uuid: lv.uuid.clone() });
                }
            }
        }
    }
    out
}

/// A failed reply that says nothing about KiCad: the CLI output did not parse, the bridge
/// answered with a generic error and no code, or the code names the transport (HTTP, relay,
/// connection, timeout). The mutation may or may not have landed; read the board before acting.
pub fn transport_error(reply: &Value) -> bool {
    if reply.get("success").and_then(|s| s.as_bool()) == Some(true) { return false; }
    match reply["errorCode"].as_str() {
        Some(code) => {
            let c = code.to_ascii_lowercase();
            if matches!(c.as_str(), "invalid_socket" | "approval_timeout" | "permission_denied") { return false; }
            ["http", "transport", "relay", "unreachable", "connection", "econn", "network", "disconnected", "timeout"].iter().any(|k| c.contains(k))
        }
        None => matches!(reply["status"].as_str(), Some("error" | "parse_error")) || (reply.get("success").is_none() && reply.get("status").is_none()),
    }
}

/// What a trace landing did.
#[derive(Clone, Debug, Default)]
pub struct Landing {
    pub replies: Vec<Value>,
    pub accepted: usize,
    /// index (0-based, in the flattened traces) of the trace that stopped the landing
    pub stopped: Option<usize>,
    pub traces: usize,
    /// already on the live board: not landed again
    pub skipped: usize,
    /// before --from
    pub before_from: usize,
    /// the reply was lost but the read-back shows the trace landed
    pub recovered: usize,
    /// landed on the one retry after a transport error
    pub retried: usize,
    /// (trace number 1-based, trace entry, reply) for every trace this landing put on the board
    pub landed: Vec<(usize, Value, Value)>,
}

/// The landing loop with the bridge calls passed in, so it runs offline in tests.
/// `read` returns the live revision and the kicad_routing_state reply; `precheck` may refuse a
/// trace before anything lands (checked for every trace first); `from` is 1-based.
#[allow(clippy::too_many_arguments)]
pub fn land_traces_with(
    board: &str,
    traces: &[Value],
    from: usize,
    mut call: impl FnMut(&str, &Value) -> Value,
    mut read: impl FnMut() -> (Option<String>, Value),
    mut precheck: impl FnMut(usize, &Value, &LiveCopper) -> Option<Value>,
    mut wait: impl FnMut(),
    mut progress: impl FnMut(usize, usize, &Value, &Value),
) -> Landing {
    let mut out = Landing { traces: traces.len(), ..Default::default() };
    let (revision, state) = read();
    let Some(mut rev) = revision else { out.replies.push(state); out.stopped = Some(0); return out };
    let live = LiveCopper::from_state(&state);
    let mut todo: Vec<usize> = Vec::new();
    for (i, e) in traces.iter().enumerate() {
        if i + 1 < from.max(1) { out.before_from += 1; continue; }
        if resolve(e, &live.pads).map(|t| present(&t, &live)).unwrap_or(false) {
            out.skipped += 1;
            progress(i + 1, traces.len(), e, &json!({"success": true, "alreadyPresent": true}));
            continue;
        }
        todo.push(i);
    }
    for &i in &todo {
        if let Some(refusal) = precheck(i, &traces[i], &live) {
            out.replies.push(refusal);
            out.stopped = Some(i);
            return out;
        }
    }
    let args_of = |e: &Value, rev: &str| {
        let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});
        for k in ["viaSize", "viaDrill", "layer"] {
            if let Some(v) = e.get(k) { args[k] = v.clone(); }
        }
        if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }
        args
    };
    let ok = |r: &Value| crate::Bridge::ok(r);
    for &i in &todo {
        let e = &traces[i];
        let mut r = call("kicad_route_net", &args_of(e, &rev));
        progress(i + 1, traces.len(), e, &r);
        if !ok(&r) && transport_error(&r) {
            // the reply was lost, not refused: the board says whether the trace landed
            let (nrev, st) = read();
            let Some(nrev) = nrev else { out.replies.push(r); out.replies.push(st); out.stopped = Some(i); return out };
            let now = LiveCopper::from_state(&st);
            if resolve(e, &now.pads).map(|t| present(&t, &now)).unwrap_or(false) {
                out.recovered += 1;
                r = json!({"success": true, "recoveredFromReadback": true, "revision": nrev, "lostReply": r});
            } else {
                let first = r;
                r = call("kicad_route_net", &args_of(e, &nrev));
                progress(i + 1, traces.len(), e, &r);
                if ok(&r) { out.retried += 1; r["retriedAfter"] = first; } else { out.replies.push(first); }
            }
        }
        if !ok(&r) {
            out.replies.push(r);
            out.stopped = Some(i);
            return out;
        }
        out.accepted += 1;
        if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) { rev = nr.to_string(); }
        out.landed.push((i + 1, e.clone(), r.clone()));
        out.replies.push(r);
        wait();
    }
    out
}

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

    /// A kicad_routing_state reply in the bridge's shape: two pads, one GND trace (pad to point to
    /// point), one SW track and one GND via.
    fn state(rev: &str, extra_segments: &[Value], extra_vias: &[Value]) -> Value {
        let mut segs = vec![
            json!({"start": {"x": 115.4, "y": 83.9}, "end": {"x": 114.6, "y": 83.9}, "layer": "F.Cu", "net_name": "GND", "width": 0.8, "uuid": "g1"}),
            json!({"start": {"x": 114.6, "y": 83.9}, "end": {"x": 114.6, "y": 86.4}, "layer": "F.Cu", "net_name": "GND", "width": 0.8, "uuid": "g2"}),
            json!({"start": {"x": 110.0, "y": 92.5}, "end": {"x": 118.0, "y": 92.5}, "layer": "F.Cu", "net_name": "/SW", "width": 0.6, "uuid": "sw1"}),
        ];
        segs.extend(extra_segments.iter().cloned());
        let mut vias = vec![json!({"position": {"x": 103.1, "y": 92.2}, "net_name": "GND", "size": 0.8, "drill": 0.4, "uuid": "v1"})];
        vias.extend(extra_vias.iter().cloned());
        json!({"success": true, "revision": rev, "segments": segs, "vias": vias, "pads": [
            {"name": "C3.2", "netName": "GND", "x": 115.4, "y": 83.9},
            {"name": "J5.1", "netName": "GND", "x": 124.0, "y": 92.0},
        ]})
    }

    fn gnd_trace() -> Value {
        json!({"net": "GND", "width": 0.8, "paths": [["C3.2", [115.4, 83.9], [114.6, 83.9], [114.6, 86.4]]]})
    }
    fn via_entry(net: &str, x: f64, y: f64) -> Value {
        json!({"net": net, "width": 0.5, "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]})
    }

    #[test]
    fn a_trace_on_the_board_is_present_within_tolerance_and_only_on_its_net() {
        let live = LiveCopper::from_state(&state("r", &[], &[]));
        let t = resolve(&gnd_trace(), &live.pads).unwrap();
        assert_eq!(t.segs.len(), 2, "the pad-to-first-point hop has no length and is not a segment");
        assert!(present(&t, &live));
        // drawn the other way, and 0.03 mm off: still the same trace
        let back = json!({"net": "GND", "width": 0.8, "points": [[114.62, 86.4], [114.6, 83.93], "C3.2"]});
        assert!(present(&resolve(&back, &live.pads).unwrap(), &live));
        let moved = json!({"net": "GND", "width": 0.8, "points": [[114.6, 86.4], [114.6, 88.0]]});
        assert!(!present(&resolve(&moved, &live.pads).unwrap(), &live));
        let other = json!({"net": "+5V", "width": 0.8, "paths": [["C3.2", [115.4, 83.9], [114.6, 83.9]]]});
        assert!(!present(&resolve(&other, &live.pads).unwrap(), &live));
        assert!(resolve(&json!({"net": "GND", "points": ["U9.1", [1.0, 1.0]]}), &live.pads).is_none(), "an unknown pad is never assumed present");
    }

    #[test]
    fn a_layer_marker_is_a_via_and_a_via_counts_only_on_its_net() {
        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 113.3, "y": 92.55}, "net_name": "/SW", "size": 0.6, "uuid": "vsw"})]));
        let t = resolve(&via_entry("GND", 103.1, 92.2), &live.pads).unwrap();
        assert_eq!((t.segs.len(), t.vias.clone()), (0, vec![(103.1, 92.2)]));
        assert!(present(&t, &live));
        // the via that KiCad handed to SW does not count as the planned GND via
        assert!(!present(&resolve(&via_entry("GND", 113.3, 92.55), &live.pads).unwrap(), &live));
        // a starting {pad, layer} sets the layer; it is not a via
        let start = json!({"net": "GND", "points": [{"pad": "J5.1", "layer": "B.Cu"}, [124.0, 95.0]]});
        assert!(resolve(&start, &live.pads).unwrap().vias.is_empty());
    }

    #[test]
    fn a_via_touching_another_nets_copper_is_refused_before_it_lands() {
        let live = LiveCopper::from_state(&state("r", &[], &[]));
        let copper = live_copper(&live);
        // the buck run: a GND thermal via whose copper overlaps the SW track by 0.05 mm
        assert_eq!(via_touch("GND", (113.3, 92.5 + 0.3 + 0.3 - 0.05), 0.3, &copper).as_deref(), Some("live /SW track"));
        assert!(via_touch("GND", (113.3, 93.5), 0.3, &copper).is_none(), "0.4 mm clear is not touching");
        assert!(via_touch("/SW", (113.3, 92.55), 0.3, &copper).is_none(), "its own net may touch");
        let pad = vec![Copper::Poly { poly: vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], net: "/SW".into(), what: "pad L1.1 (/SW)".into() }];
        assert!(via_touch("GND", (1.2, 0.5), 0.3, &pad).is_some());
        assert!(via_touch("GND", (0.5, 0.5), 0.1, &pad).is_some(), "inside the pad");
        assert!(via_touch("GND", (1.5, 0.5), 0.3, &pad).is_none());
        let plan = json!({"nets": [{"net": "/SW", "width": 0.6, "paths": [[[110.0, 80.0], [118.0, 80.0]]]}]});
        assert_eq!(via_touch("GND", (112.0, 80.4), 0.3, &plan_copper(&plan, &live.pads)).as_deref(), Some("planned /SW track"));
    }

    #[test]
    fn a_planned_via_over_an_existing_same_net_via_is_refused() {
        // issue #83: an in-pad 0.5/0.25 GND via on the live board, and a planned 0.8/0.4 GND via 0.6 mm away
        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 112.4, "y": 82.0}, "net_name": "GND", "size": 0.5, "drill": 0.25, "uuid": "ep1"})]));
        let holes = live_holes(&live);
        let clash = via_hole_clash("GND", (111.8, 82.0), 0.8, 0.4, &holes, 0.25, 0.2).expect("refused");
        assert!(clash.contains("live GND via at (112.40, 82.00)") && clash.contains("0.60 mm apart"), "{clash}");
        // with a hole-to-hole minimum the drills pass, the copper spacing still refuses it
        assert!(via_hole_clash("GND", (111.8, 82.0), 0.8, 0.4, &holes, 0.2, 0.2).unwrap().contains("via-to-via"));
        assert!(via_hole_clash("GND", (111.4, 82.0), 0.8, 0.4, &holes, 0.25, 0.2).is_none(), "1.0 mm away is clear");
        assert!(via_hole_clash("GND", (112.4, 82.0), 0.5, 0.25, &holes, 0.25, 0.2).is_none(), "the same via is not a clash");
        // a planned via of another plan counts the same way
        let plan = json!({"nets": [via_entry("GND", 120.0, 80.0)]});
        assert!(via_hole_clash("GND", (120.5, 80.0), 0.6, 0.3, &plan_holes(&plan, &live.pads), 0.25, 0.2).is_some());
    }

    #[test]
    fn a_landed_via_on_another_net_is_named_with_both_nets() {
        let live = LiveCopper::from_state(&state("r", &[], &[json!({"position": {"x": 113.3, "y": 92.55}, "net_name": "/SW", "size": 0.6, "uuid": "vsw"})]));
        let landed = vec![
            (1, resolve(&via_entry("GND", 103.1, 92.2), &live.pads).unwrap()),
            (2, resolve(&via_entry("GND", 113.3, 92.55), &live.pads).unwrap()),
            (3, resolve(&via_entry("GND", 50.0, 50.0), &live.pads).unwrap()),
        ];
        let m = via_net_mismatches(&landed, &live);
        assert_eq!(m, vec![ViaMismatch { trace: 2, planned: "GND".into(), actual: "/SW".into(), at: (113.3, 92.55), uuid: "vsw".into() }]);
    }

    #[test]
    fn transport_errors_are_told_from_kicad_refusals() {
        for r in [json!({"status": "parse_error", "raw": ""}), json!({"status": "error", "error": "adom-bridge not runnable"}), json!({}),
                  json!({"success": false, "errorCode": "http_error"}), json!({"success": false, "errorCode": "ipc_timeout"}), json!({"success": false, "errorCode": "relay_disconnected"})] {
            assert!(transport_error(&r), "{r}");
        }
        for r in [json!({"success": false, "errorCode": "drc_rejected"}), json!({"success": false, "errorCode": "stale_revision"}), json!({"success": false, "errorCode": "approval_timeout"}),
                  json!({"success": false, "errorCode": "invalid_socket"}), json!({"success": true, "revision": "x"})] {
            assert!(!transport_error(&r), "{r}");
        }
    }

    /// Run the loop with scripted replies; returns the landing and the kicad_route_net calls made.
    fn run(traces: &[Value], from: usize, replies: Vec<Value>, states: Vec<Value>, refuse: bool) -> (Landing, Vec<Value>) {
        let mut replies = replies.into_iter();
        let mut states = states.into_iter();
        let mut calls = Vec::new();
        let l = land_traces_with("B", traces, from,
            |verb, args| { assert_eq!(verb, "kicad_route_net"); calls.push(args.clone()); replies.next().expect("an unexpected kicad_route_net call") },
            || { let s = states.next().expect("an unexpected read"); (s["revision"].as_str().map(str::to_string), s) },
            |_, _, _| if refuse { Some(json!({"success": false, "errorCode": "via_touches_other_net"})) } else { None },
            || {}, |_, _, _, _| {});
        (l, calls)
    }

    fn trace(x: f64) -> Value {
        json!({"net": "+5V", "width": 0.4, "points": [[x, 10.0], [x, 20.0]]})
    }
    fn seg(x: f64) -> Value {
        json!({"start": {"x": x, "y": 10.0}, "end": {"x": x, "y": 20.0}, "layer": "F.Cu", "net_name": "+5V", "width": 0.4, "uuid": format!("s{x}")})
    }
    fn good(rev: &str) -> Value {
        json!({"success": true, "revision": rev, "itemIds": ["i"]})
    }

    #[test]
    fn a_rerun_skips_the_traces_already_landed() {
        let traces: Vec<Value> = (1..=4).map(|x| trace(x as f64)).collect();
        let (l, calls) = run(&traces, 1, vec![good("r3"), good("r4")], vec![state("r2", &[seg(1.0), seg(2.0)], &[])], false);
        assert_eq!((l.skipped, l.accepted, l.stopped, calls.len()), (2, 2, None, 2));
        assert_eq!(calls[0]["points"][0][0], 3.0);
        assert_eq!(calls[0]["expectedRevision"], "r2");
        assert_eq!(calls[1]["expectedRevision"], "r3");
        assert_eq!(l.landed.iter().map(|x| x.0).collect::<Vec<_>>(), vec![3, 4]);
    }

    #[test]
    fn from_starts_at_the_given_trace() {
        let traces: Vec<Value> = (1..=4).map(|x| trace(x as f64)).collect();
        let (l, calls) = run(&traces, 4, vec![good("r1")], vec![state("r0", &[], &[])], false);
        assert_eq!((l.before_from, l.accepted, calls.len()), (3, 1, 1));
        assert_eq!(calls[0]["points"][0][0], 4.0);
    }

    #[test]
    fn a_lost_reply_is_retried_once_only_when_the_trace_did_not_land() {
        let traces = vec![trace(1.0), trace(2.0)];
        let lost = json!({"status": "error", "error": "HTTP error from the local port"});
        // not landed: one retry at the re-read revision, then on
        let (l, calls) = run(&traces, 1, vec![lost.clone(), good("r2"), good("r3")], vec![state("r0", &[], &[]), state("r1", &[], &[])], false);
        assert_eq!((l.accepted, l.retried, l.recovered, l.stopped, calls.len()), (2, 1, 0, None, 3));
        assert_eq!(calls[1]["expectedRevision"], "r1");
        // landed although the reply was lost: counted, never sent again
        let (l, calls) = run(&traces, 1, vec![lost.clone(), good("r3")], vec![state("r0", &[], &[]), state("r1", &[seg(1.0)], &[])], false);
        assert_eq!((l.accepted, l.retried, l.recovered, l.stopped, calls.len()), (2, 0, 1, None, 2));
        assert_eq!(calls[1]["expectedRevision"], "r1");
        // lost twice: stop at that trace, the second trace is not attempted
        let (l, calls) = run(&traces, 1, vec![lost.clone(), lost.clone()], vec![state("r0", &[], &[]), state("r1", &[], &[])], false);
        assert_eq!((l.accepted, l.stopped, calls.len()), (0, Some(0), 2));
    }

    #[test]
    fn a_kicad_refusal_stops_without_a_retry() {
        let traces = vec![trace(1.0), trace(2.0)];
        let (l, calls) = run(&traces, 1, vec![good("r1"), json!({"success": false, "errorCode": "drc_rejected"})], vec![state("r0", &[], &[])], false);
        assert_eq!((l.accepted, l.stopped, calls.len()), (1, Some(1), 2));
    }

    #[test]
    fn a_precheck_refusal_lands_nothing() {
        let traces = vec![trace(1.0), trace(2.0)];
        let (l, calls) = run(&traces, 1, vec![], vec![state("r0", &[], &[])], true);
        assert_eq!((l.accepted, l.stopped, calls.len()), (0, Some(0), 0));
        assert_eq!(l.replies[0]["errorCode"], "via_touches_other_net");
    }
}