master
John Lauer 1.0.1: 1.0.1: Freerouting on the user's terms. kicad_freerouting {status|install|uninstall} installs Freerouting's own… 471d9cf 1mo ago
//! Freerouting end to end on the six-net fixture from `demo/routing/make_fixture.py`
//! (rebuilt here from the blank template, as tests/routing_file.rs does): board -> DSN
//! (`dsn::write_dsn`) -> Freerouting headless (`freerouting::run`) -> SES (`dsn::read_ses`)
//! -> copper applied to a copy of the board (`freerouting::apply_to_file`) -> a real KiCad
//! 10 DRC through `service-kicad` that must report zero errors and zero unconnected items
//! (the three `lib_footprint_issues` warnings are the deliberately uninstalled fixture
//! library). Skipped, loudly, when no Freerouting executable is around: the
//! `ADOM_FREEROUTING_EXE` override, the bridge's own install folder, or the scratchpad
//! bundle unpacked while this engine was built.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

use serde_json::Value;

use kicad_core::schematic::new_uuid;
use kicad_core::{dsn, freerouting, pcb};

const SCRATCH_BUNDLE: &str = "/tmp/claude-1001/-home-adom-project/a82462cd-99a1-4a08-b142-383081e6ec8a/scratchpad/fr/freerouting-2.4.1-linux-x64/bin/freerouting";

fn repo_file(rel: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}

fn temp_dir(tag: &str) -> PathBuf {
    let d = std::env::temp_dir().join(format!("kicad-core-freerouting-{tag}-{}-{}", std::process::id(), new_uuid()));
    std::fs::create_dir_all(&d).unwrap();
    d
}

fn on_path(name: &str) -> Option<PathBuf> {
    let path = std::env::var_os("PATH")?;
    std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}

fn freerouting_exe() -> Option<PathBuf> {
    if let Some(p) = std::env::var_os(freerouting::ENV_EXE).map(PathBuf::from).filter(|p| p.is_file()) {
        return Some(p);
    }
    if let Some(p) = freerouting::find_exe(&freerouting::dir()) {
        return Some(p);
    }
    Some(PathBuf::from(SCRATCH_BUNDLE)).filter(|p| p.is_file())
}

/// `demo/routing/make_fixture.py`: J1 and J2 with six pads each on NET_1..NET_6, J3 with
/// one pad on NET_6, an Edge.Cuts rectangle, KiCad 10 dialect.
fn fixture_text() -> String {
    let base = std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).expect("blank-board template");
    let mut blocks = String::new();
    let mut footprint = |r: &str, x: f64, y: f64, pads: Vec<(String, f64, f64, String)>| {
        blocks.push_str(&format!("(footprint \"RoutingFixture:{r}\" (layer \"F.Cu\") (uuid \"{}\") (at {x} {y})\n", new_uuid()));
        blocks.push_str(&format!("(property \"Reference\" \"{r}\" (at 0 -3) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))\n"));
        blocks.push_str("(property \"Value\" \"ROUTING_TEST\" (at 0 -5) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n(attr through_hole exclude_from_pos_files exclude_from_bom)\n");
        for (n, px, py, net) in pads {
            blocks.push_str(&format!("(pad \"{n}\" thru_hole circle (at {px} {py}) (size 2 2) (drill 1) (layers \"*.Cu\" \"*.Mask\") (net \"{net}\") (uuid \"{}\"))\n", new_uuid()));
        }
        blocks.push_str(")\n");
    };
    let six = || (1..=6).map(|i| (i.to_string(), 0.0, ((i - 1) * 5) as f64, format!("NET_{i}"))).collect::<Vec<_>>();
    footprint("J1", 105.0, 85.0, six());
    footprint("J2", 145.0, 85.0, six());
    footprint("J3", 125.0, 116.0, vec![("1".into(), 0.0, 0.0, "NET_6".into())]);
    blocks.push_str(&format!("(gr_rect (start 98 78) (end 152 122) (stroke (width 0.05) (type default)) (fill none) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", new_uuid()));
    let idx = base.rfind(')').unwrap();
    format!("{}{}\n)\n", &base[..idx], blocks)
}

fn drc(svc: &Path, board: &Path, report: &Path) -> Value {
    let out = Command::new(svc).args(["pcb", "drc"]).arg(board).args(["--format", "json", "--out"]).arg(report).output().expect("service-kicad runs");
    assert!(report.is_file(), "service-kicad pcb drc wrote no report: {}\n{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));
    let rep: Value = serde_json::from_str(&std::fs::read_to_string(report).unwrap()).expect("DRC report is JSON");
    assert!(rep.get("kicad_version").is_some(), "not a DRC report: {rep}");
    rep
}

#[test]
fn freerouting_routes_the_six_net_fixture_to_a_clean_drc() {
    let Some(exe) = freerouting_exe() else {
        eprintln!("SKIPPED: no Freerouting executable ({} unset, nothing under {}, no scratchpad bundle at {SCRATCH_BUNDLE})", freerouting::ENV_EXE, freerouting::dir().display());
        return;
    };
    let dir = temp_dir("route");
    eprintln!("work dir {}", dir.display());
    let path = dir.join("live-routing.kicad_pcb");
    std::fs::write(&path, fixture_text()).unwrap();
    let loaded = pcb::load(&serde_json::json!({"filePath": path.to_string_lossy()}), false).unwrap();
    let board = &loaded.board;
    assert_eq!(pcb::pads(board).len(), 13);

    // ---- DSN
    let rules = dsn::rules_for(&path, board);
    assert_eq!(rules.source, "kicad-defaults");
    let out = dsn::write_dsn(board, &loaded.text, "live-routing", &rules, None).unwrap();
    assert_eq!(out.stats.routed_nets.len(), 6);
    let dsn_path = dir.join("live-routing.dsn");
    std::fs::write(&dsn_path, &out.text).unwrap();

    // ---- Freerouting
    let version = freerouting::probe_version(&exe).expect("--help answers");
    assert!(version.starts_with("2."), "{version}");
    let ses_path = dir.join("live-routing.ses");
    let mut lines = 0usize;
    let run = freerouting::run(&exe, &dsn_path, &ses_path, &dir, 20, Some(2), Duration::from_secs(300), |line| {
        lines += 1;
        if lines <= 60 {
            eprintln!("  fr> {line}");
        }
    })
    .expect("Freerouting starts");
    eprintln!("Freerouting {} exit {} timedOut {} passes {} seconds {} ({} log lines)", run.version.as_deref().unwrap_or("?"), run.exit_code, run.timed_out, run.passes_seen, run.seconds, lines);
    for l in &run.stdout_tail {
        eprintln!("  tail> {l}");
    }
    assert!(!run.timed_out, "Freerouting hit the deadline");
    assert!(ses_path.is_file(), "Freerouting wrote no session file (exit {})", run.exit_code);
    assert_eq!(run.version.as_deref(), Some(version.as_str()));

    // ---- SES -> copper -> file
    let ses_text = std::fs::read_to_string(&ses_path).unwrap();
    let session = dsn::read_ses(&ses_text).expect("session parses");
    let copper = dsn::copper_for(&session, board, None, &rules);
    let mut routed: Vec<&str> = copper.iter().map(|c| c.net.as_str()).collect();
    routed.sort();
    assert_eq!(routed, ["NET_1", "NET_2", "NET_3", "NET_4", "NET_5", "NET_6"], "every net got copper: {:?}", session.nets);
    let (bak, segs, vias) = freerouting::apply_to_file(&loaded, &copper, true).expect("write accepted");
    eprintln!("applied {segs} segments and {vias} vias; backup {:?}", bak);
    assert!(segs >= 6, "at least one segment per net");
    assert!(bak.as_ref().map(|b| Path::new(b).is_file()).unwrap_or(false));
    let after = pcb::parse_pcb(&path).unwrap();
    assert_eq!(after.segments.len(), segs);
    assert_eq!(after.vias.len(), vias);
    assert_eq!(pcb::pads(&after), pcb::pads(board), "the splice never touches footprints");
    for v in &after.vias {
        assert_eq!(v.layers.as_ref().unwrap(), &["F.Cu", "B.Cu"]);
        assert!(v.drill.unwrap() < v.size.unwrap());
    }

    // ---- KiCad's verdict
    let Some(svc) = on_path("service-kicad") else {
        eprintln!("service-kicad not on PATH; copper applied but not DRC-checked here");
        return;
    };
    let report = drc(&svc, &path, &dir.join("r.json"));
    let result = kicad_core::cli::summarize_report(&report);
    eprintln!("service-kicad DRC on the Freerouting result: {} error(s), {} warning(s), {} unconnected; kicad {}", result["errors"], result["warnings"], result["unconnected"], report["kicad_version"]);
    for v in result["first"].as_array().unwrap() {
        eprintln!("  {} {}: {}", v["severity"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["description"].as_str().unwrap_or(""));
    }
    assert_eq!(result["unconnected"], 0, "every net is connected by the router's copper");
    assert_eq!(result["errors"], 0, "no DRC errors: {}", result["first"]);
    let types: Vec<&str> = result["first"].as_array().unwrap().iter().filter_map(|v| v["type"].as_str()).collect();
    assert!(types.iter().all(|t| *t == "lib_footprint_issues"), "only the fixture library warnings expected: {types:?}");
    eprintln!("RESULT passes={} seconds={} segments={segs} vias={vias} drcErrors=0 unconnected=0 warnings={}", run.passes_seen, run.seconds, result["warnings"]);
    let _ = std::fs::remove_dir_all(&dir);
}

/// The Linux install path without the network: `ADOM_FREEROUTING_ZIP` names a local copy
/// of the release zip; the std-only zip reader unpacks it, the platform layer fixes the
/// modes, and the launcher answers `--help`. Skipped when the variable is unset.
#[test]
fn extracts_a_local_bundle_and_verifies_it() {
    let Some(zip) = std::env::var_os("ADOM_FREEROUTING_ZIP").map(PathBuf::from).filter(|p| p.is_file()) else {
        eprintln!("SKIPPED: ADOM_FREEROUTING_ZIP unset");
        return;
    };
    let dir = temp_dir("extract");
    let t = std::time::Instant::now();
    let exe = freerouting::extract_bundle(&zip, &dir).expect("extract");
    eprintln!("extracted to {} in {:.1} s", exe.display(), t.elapsed().as_secs_f64());
    assert!(exe.is_file());
    let v = freerouting::probe_version(&exe).expect("--help answers");
    eprintln!("Freerouting {v}");
    assert_eq!(v, freerouting::VERSION);
    let _ = std::fs::remove_dir_all(&dir);
}