"""KiCad PCB ingest — parse only, no SI/creepage checks. Favor: footprint+pad net; named nets; segment on a net. Against: .kicad_sch rejected; missing Reference skipped; empty board ok. """ from __future__ import annotations from pathlib import Path import pytest from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb _PCB = """(kicad_pcb (version 20240108) (generator pcbnew) (net 0 "") (net 1 "GND") (net 2 "+3V3") (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 10 20 0) (property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1)))) (property "Value" "10k" (at 0 0 0) (effects (font (size 1 1)))) (pad "1" smd roundrect (at -0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3")) (pad "2" smd roundrect (at 0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND")) ) (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 0 0 0) (property "Reference" "#PWR01" (at 0 0 0) (effects (font (size 1 1)))) (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")) ) (segment (start 10 20) (end 12 20) (width 0.25) (layer "F.Cu") (net 1)) (via (at 11 20) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1)) ) """ def test_parse_footprint_pads_and_nets(tmp_path: Path): p = tmp_path / "board.kicad_pcb" p.write_text(_PCB) g = parse_kicad_pcb(p) assert "GND" in g.nets and "+3V3" in g.nets assert "R1" in g.footprints r1 = g.footprints["R1"] assert r1.x == 10 and r1.y == 20 by_num = {pad.number: pad for pad in r1.pads} assert by_num["1"].net == "+3V3" assert by_num["2"].net == "GND" assert by_num["1"].x == pytest.approx(9.25) assert by_num["2"].x == pytest.approx(10.75) assert r1.courtyard == [] def test_parse_segment_and_via_resolve_net_name(tmp_path: Path): p = tmp_path / "board.kicad_pcb" p.write_text(_PCB) g = parse_kicad_pcb(p) assert len(g.segments) == 1 assert g.segments[0].net == "GND" assert len(g.vias) == 1 assert g.vias[0].net == "GND" def test_power_flag_footprint_is_skipped(tmp_path: Path): p = tmp_path / "board.kicad_pcb" p.write_text(_PCB) g = parse_kicad_pcb(p) assert "#PWR01" not in g.footprints def test_kicad_sch_is_rejected_as_pcb(tmp_path: Path): p = tmp_path / "sheet.kicad_sch" p.write_text("(kicad_sch (version 20250114) (uuid \"1\"))\n") with pytest.raises(ValueError, match="kicad_pcb"): parse_kicad_pcb(p) def test_empty_board_parses(tmp_path: Path): p = tmp_path / "empty.kicad_pcb" p.write_text("(kicad_pcb (version 20240108) (generator pcbnew))\n") g = parse_kicad_pcb(p) assert g.footprints == {} assert g.segments == [] _PCB_V10 = """(kicad_pcb (version 20260206) (generator pcbnew) (footprint "Package_TO_SOT_SMD:SOT-23-5" (layer "F.Cu") (at 0 0 0) (property "Reference" "U3" (at 0 0 0) (effects (font (size 1 1)))) (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VSYS")) (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "GND")) (pad "5" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL")) ) (footprint "RF_Module:ESP" (layer "F.Cu") (at 10 0 0) (property "Reference" "U5" (at 0 0 0) (effects (font (size 1 1)))) (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND")) (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL")) (pad "3" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "/ESP32_EN")) ) ) """ def test_kicad10_pad_net_string_form(tmp_path: Path): from backend.periscopex.parsers_kicad_pcb import nets_from_pcb p = tmp_path / "v10.kicad_pcb" p.write_text(_PCB_V10) g = parse_kicad_pcb(p) assert g.footprints["U3"].pads[1].net == "GND" nets = nets_from_pcb(g) assert ("U3", "2") in nets["GND"] assert ("U3", "5") in nets["3V3_DIGITAL"] assert ("U5", "3") in nets["ESP32_EN"] # leading / stripped def test_build_graph_prefers_pcb_nets_over_sch(tmp_path: Path): """Board pad nets win when sch geometry would swap rails.""" from backend.periscopex.graph import build_graph sch = tmp_path / "netlist.kicad_sch" # Minimal sch: only needs to parse as kicad_sch with some parts. sch.write_text("""(kicad_sch (version 20250114) (uuid "1") (lib_symbols (symbol "Device:R" (pin passive (at 0 3.81 90) (length 2.54) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))) ) (pin passive (at 0 -3.81 90) (length 2.54) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))) ) ) ) (symbol (lib_id "Device:R") (at 0 0 0) (unit 1) (uuid "a") (property "Reference" "R1" (at 0 0 0) (effects (font (size 1.27 1.27)))) (property "Value" "10k" (at 0 0 0) (effects (font (size 1.27 1.27)))) (pin "1" (uuid "p1")) (pin "2" (uuid "p2")) ) (global_label "GND" (at 0 3.81 0) (uuid "b")) ) """) pcb = tmp_path / "pcb.kicad_pcb" pcb.write_text(_PCB_V10) bom = tmp_path / "bom.csv" bom.write_text( "Reference,Value,Footprint,Manufacturer Part Number\n" "U3,AP2112,SOT23,\nU5,ESP32,,\nR1,10k,,\n" ) g = build_graph( sch, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod", pcb_path=pcb, ) assert g.components["U3"].pins["2"] == "GND" assert g.components["U3"].pins["5"] == "3V3_DIGITAL" assert g.components["U5"].pins["1"] == "GND" assert g.components["U5"].pins["2"] == "3V3_DIGITAL" def test_upload_pcb_sets_has_pcb(tmp_path: Path): from fastapi.testclient import TestClient from backend.main import app from backend.services.storage import LocalStorageBackend app.state.storage = LocalStorageBackend(tmp_path) client = TestClient(app) pid = client.post("/api/projects", json={"name": "board"}).json()["id"] resp = client.post( f"/api/projects/{pid}/upload/pcb", files={"file": ("board.kicad_pcb", _PCB.encode(), "text/plain")}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["footprints"] == 1 assert body["nets"] >= 2 fresh = client.get(f"/api/projects/{pid}").json() assert fresh["has_pcb"] is True assert fresh["has_netlist"] is False def test_upload_sch_as_pcb_is_rejected(tmp_path: Path): from fastapi.testclient import TestClient from backend.main import app from backend.services.storage import LocalStorageBackend app.state.storage = LocalStorageBackend(tmp_path) client = TestClient(app) pid = client.post("/api/projects", json={"name": "board"}).json()["id"] resp = client.post( f"/api/projects/{pid}/upload/pcb", files={"file": ("sheet.kicad_sch", b"(kicad_sch (version 1))\n", "text/plain")}, ) assert resp.status_code == 400 fresh = client.get(f"/api/projects/{pid}").json() assert not fresh.get("has_pcb") class _FakeWs: def __init__(self, root: Path): self.root = root def local_path(self, rel: str) -> Path: return self.root / rel def test_layout_graph_skipped_when_pcb_missing(tmp_path: Path): from backend.services.pipeline import _write_layout_graph (tmp_path / "uploads").mkdir() _write_layout_graph(_FakeWs(tmp_path), "p1") assert not (tmp_path / "layout_graph.json").is_file() def test_layout_graph_fail_soft_on_invalid_pcb(tmp_path: Path): from backend.services.pipeline import _write_layout_graph uploads = tmp_path / "uploads" uploads.mkdir() (uploads / "pcb.kicad_pcb").write_text("(kicad_sch (version 1))\n") _write_layout_graph(_FakeWs(tmp_path), "p1") assert not (tmp_path / "layout_graph.json").is_file() def test_layout_graph_written_from_valid_pcb(tmp_path: Path): from backend.services.pipeline import _write_layout_graph uploads = tmp_path / "uploads" uploads.mkdir() (uploads / "pcb.kicad_pcb").write_text(_PCB) _write_layout_graph(_FakeWs(tmp_path), "p1") out = tmp_path / "layout_graph.json" assert out.is_file() data = __import__("json").loads(out.read_text()) assert "R1" in data["footprints"] def test_stackup_thickness_without_full_dielectrics(tmp_path: Path): p = tmp_path / "t.kicad_pcb" p.write_text("""(kicad_pcb (version 20240108) (net 0 "") (net 1 "VOUT") (setup (stackup (layer "F.Cu" (type "copper") (thickness 0.035mm)) (layer "B.Cu" (type "copper") (thickness 0.035)) ) ) (via (at 1 1) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1)) (arc (start 0 0) (end 2 0) (width 0.4) (layer "F.Cu") (net 1)) ) """) g = parse_kicad_pcb(p) assert g.stackup is not None assert g.stackup.copper_thickness_mm == pytest.approx(0.035) assert len(g.vias) == 1 assert any(s.width == pytest.approx(0.4) and s.net == "VOUT" for s in g.segments)