Ship Sprint 0 CAD foundations so findings, KiCad hierarchy, BOM match, eval, and optional PCB ingest have a stable contract.
Extend Finding with rule_id/net/pins, flatten multi-sheet .kicad_sch, flag MPN mismatches, score simple_project, and parse .kicad_pcb without running layout DRC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""BOM vs schematic MPN/value match.
|
||||
|
||||
Favor: identical MPNs silent; real mismatch is ERROR PS-BOM-001 with
|
||||
designator; orphan BOM line is WARNING.
|
||||
Against: case/whitespace-only MPN is not a mismatch; empty schematic map
|
||||
skips the check (PADS path); BOM-empty + schematic MPN is fill, not ERROR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.bom_match_check import check_bom_schematic_match
|
||||
from backend.pinscopex.models import DesignGraph
|
||||
|
||||
|
||||
def test_matching_mpns_produce_no_findings():
|
||||
sch = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "3.3V LDO"}}
|
||||
bom = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "3.3V LDO"}}
|
||||
assert check_bom_schematic_match(sch, bom) == []
|
||||
|
||||
|
||||
def test_mpn_mismatch_is_error_ps_bom_001():
|
||||
sch = {"U1": {"mpn": "SPX3819M5-L-3-3", "value": "LDO"}}
|
||||
bom = {"U1": {"mpn": "AMS1117-3.3", "value": "LDO"}}
|
||||
findings = check_bom_schematic_match(sch, bom)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f.rule_id == "PS-BOM-001"
|
||||
assert f.source == "bom_match"
|
||||
assert f.status == "ERROR"
|
||||
assert f.designator == "U1"
|
||||
assert "SPX3819M5-L-3-3" in f.finding
|
||||
assert "AMS1117-3.3" in f.finding
|
||||
|
||||
|
||||
def test_orphan_bom_ref_is_warning():
|
||||
sch = {"U1": {"mpn": "MCUX", "value": "MCU"}}
|
||||
bom = {
|
||||
"U1": {"mpn": "MCUX", "value": "MCU"},
|
||||
"R99": {"mpn": "RC0603", "value": "10k"},
|
||||
}
|
||||
findings = check_bom_schematic_match(sch, bom)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PS-BOM-002"
|
||||
assert findings[0].status == "WARNING"
|
||||
assert findings[0].designator == "R99"
|
||||
|
||||
|
||||
def test_mpn_case_and_whitespace_are_not_a_mismatch():
|
||||
sch = {"U1": {"mpn": " mspm0g3507sptr ", "value": "MCU"}}
|
||||
bom = {"U1": {"mpn": "MSPM0G3507SPTR", "value": "MCU"}}
|
||||
assert check_bom_schematic_match(sch, bom) == []
|
||||
|
||||
|
||||
def test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch():
|
||||
sch = {"U1": {"mpn": "MSPM0G3507SPTR", "value": "MCU"}}
|
||||
bom = {"U1": {"mpn": None, "value": "MSPM0"}}
|
||||
assert check_bom_schematic_match(sch, bom) == []
|
||||
|
||||
|
||||
def test_empty_schematic_map_skips_check():
|
||||
"""PADS/EDIF graphs have no schematic property table — do not treat
|
||||
every BOM line as an orphan."""
|
||||
sch = {}
|
||||
bom = {"U1": {"mpn": "MCUX", "value": "MCU"}, "R1": {"mpn": "RC", "value": "10k"}}
|
||||
assert check_bom_schematic_match(sch, bom) == []
|
||||
|
||||
|
||||
def test_legacy_design_graph_without_source_fields_still_validates():
|
||||
g = DesignGraph.model_validate({
|
||||
"components": {},
|
||||
"nets": {},
|
||||
})
|
||||
assert g.bom_fields == {}
|
||||
assert g.schematic_fields == {}
|
||||
|
||||
|
||||
def test_build_graph_kicad_mpn_mismatch_surfaces(tmp_path):
|
||||
from backend.pinscopex.graph import build_graph
|
||||
|
||||
net = tmp_path / "net.xml"
|
||||
net.write_text(
|
||||
"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<export version="E">
|
||||
<components>
|
||||
<comp ref="U1">
|
||||
<value>LDO</value>
|
||||
<fields><field name="MPN">SPX3819M5-L-3-3</field></fields>
|
||||
</comp>
|
||||
</components>
|
||||
<nets>
|
||||
<net code="1" name="GND"><node ref="U1" pin="2"/></net>
|
||||
</nets>
|
||||
</export>
|
||||
"""
|
||||
)
|
||||
bom = tmp_path / "bom.csv"
|
||||
bom.write_text(
|
||||
"Reference,Value,Footprint,Manufacturer Part Number\n"
|
||||
"U1,LDO,,AMS1117-3.3\n"
|
||||
)
|
||||
g = build_graph(
|
||||
net, bom, tmp_path / "empty_ex", tmp_path / "empty_pat", tmp_path / "empty_mod",
|
||||
)
|
||||
findings = check_bom_schematic_match(g.schematic_fields, g.bom_fields)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PS-BOM-001"
|
||||
assert findings[0].designator == "U1"
|
||||
@@ -65,6 +65,30 @@ def test_singletons_pass_through_unchanged():
|
||||
assert [f.designator for f in built] == ["U2", "U3"]
|
||||
|
||||
|
||||
def test_dedupe_passthrough_keeps_cad_fields():
|
||||
originals = [
|
||||
Finding(
|
||||
designator="U2",
|
||||
mpn="MPN1",
|
||||
finding="finding 1",
|
||||
why="w",
|
||||
status="ERROR",
|
||||
recommendation="",
|
||||
source_page=1,
|
||||
reference="ref 1",
|
||||
net="USB_D+",
|
||||
pins=["U2.1"],
|
||||
rule_id="PS-USB-001",
|
||||
)
|
||||
]
|
||||
groups = [{"member_indices": [1], "change_rationale": "passthrough"}]
|
||||
built = _build_deduped(groups, originals)
|
||||
assert built is not None
|
||||
assert built[0].rule_id == "PS-USB-001"
|
||||
assert built[0].net == "USB_D+"
|
||||
assert built[0].pins == ["U2.1"]
|
||||
|
||||
|
||||
def test_merge_collapses_interface_and_uses_primary_source():
|
||||
"""U2-001 + U3-001 → one finding. primary_index picks which side supplies
|
||||
the canonical designator + datasheet citation."""
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Eval harness — finding count, citation hit-rate, precision/recall.
|
||||
|
||||
Favor: perfect golden match; citation rate ignores deterministic findings;
|
||||
simple_project graph still has U1/U2/U3 and the two I2C pull-up keys.
|
||||
Against: extra finding drops precision; missing golden key drops recall;
|
||||
Unverified quotes are citation misses, not hits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.eval_report import (
|
||||
citation_hit_rate,
|
||||
eval_simple_project,
|
||||
finding_key,
|
||||
score_report,
|
||||
)
|
||||
from backend.pinscopex.models import Finding
|
||||
|
||||
|
||||
def _f(**kwargs) -> Finding:
|
||||
defaults = dict(designator="U1", finding="x", status="WARNING")
|
||||
defaults.update(kwargs)
|
||||
return Finding(**defaults)
|
||||
|
||||
|
||||
def test_perfect_match_is_precision_and_recall_one():
|
||||
f = _f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")
|
||||
scores = score_report([f], {finding_key(f)})
|
||||
assert scores.precision == 1.0
|
||||
assert scores.recall == 1.0
|
||||
assert scores.finding_count == 1
|
||||
assert scores.extra_keys == []
|
||||
assert scores.missing_keys == []
|
||||
|
||||
|
||||
def test_extra_finding_drops_precision_not_recall():
|
||||
gold = _f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")
|
||||
extra = _f(rule_id="PS-BOM-001", designator="U1", net="")
|
||||
scores = score_report([gold, extra], {finding_key(gold)})
|
||||
assert scores.recall == 1.0
|
||||
assert scores.precision == 0.5
|
||||
assert scores.extra_keys == ["PS-BOM-001|U1|"]
|
||||
|
||||
|
||||
def test_missing_golden_key_drops_recall():
|
||||
gold_a = "PS-I2C-001|U3|/I2C0.SDA"
|
||||
gold_b = "PS-I2C-001|U3|/I2C0.SCL"
|
||||
produced = [_f(rule_id="PS-I2C-001", designator="U3", net="/I2C0.SDA")]
|
||||
scores = score_report([produced[0]], {gold_a, gold_b})
|
||||
assert scores.precision == 1.0
|
||||
assert scores.recall == 0.5
|
||||
assert scores.missing_keys == [gold_b]
|
||||
|
||||
|
||||
def test_citation_rate_ignores_deterministic_and_counts_unverified():
|
||||
det = _f(
|
||||
source="i2c_pullup_check",
|
||||
rule_id="PS-I2C-001",
|
||||
source_quote="ignored because deterministic",
|
||||
why="no pull-up",
|
||||
)
|
||||
ok = _f(
|
||||
source="review",
|
||||
source_quote="Connect a 100 nF capacitor close to VDD.",
|
||||
why="missing cap",
|
||||
status="ERROR",
|
||||
)
|
||||
bad = _f(
|
||||
source="review",
|
||||
source_quote="This quote is long enough to count.",
|
||||
why="Unverified: cited text not found in the datasheet.",
|
||||
status="WARNING",
|
||||
)
|
||||
assert citation_hit_rate([det, ok, bad]) == 0.5
|
||||
scores = score_report([det, ok, bad], {finding_key(det)})
|
||||
assert scores.unverified_pct == 100.0 / 3
|
||||
assert scores.citation_hit_rate == 0.5
|
||||
|
||||
|
||||
def test_simple_project_eval_matches_committed_golden():
|
||||
root = Path(__file__).resolve().parents[1] / "simple_project"
|
||||
scores = eval_simple_project(root)
|
||||
assert scores.graph_ok, scores.graph_errors
|
||||
assert scores.precision == 1.0
|
||||
assert scores.recall == 1.0
|
||||
assert scores.finding_count == 2
|
||||
assert scores.by_status["WARNING"] == 2
|
||||
|
||||
|
||||
def test_simple_project_eval_rejects_truncated_graph(tmp_path: Path):
|
||||
import json
|
||||
import shutil
|
||||
|
||||
src = Path(__file__).resolve().parents[1] / "simple_project"
|
||||
dest = tmp_path / "simple_project"
|
||||
shutil.copytree(src, dest)
|
||||
g = json.loads((dest / "design_graph.json").read_text())
|
||||
g["components"] = {"R1": g["components"]["R1"]}
|
||||
(dest / "design_graph.json").write_text(json.dumps(g))
|
||||
scores = eval_simple_project(dest)
|
||||
assert scores.graph_ok is False
|
||||
assert any("U1" in e or "missing ref" in e for e in scores.graph_errors)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Finding schema — optional CAD/plugin fields, backward compatible.
|
||||
|
||||
Favor: legacy JSON still validates; new fields round-trip; pin-mux
|
||||
fills rule_id + net + pins.
|
||||
Against: invalid status rejected; pins must be a list; extra junk status
|
||||
does not silently coerce.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
|
||||
|
||||
def test_legacy_json_without_new_fields_still_validates():
|
||||
raw = {
|
||||
"designator": "U3",
|
||||
"mpn": "MSPM0G3507SPTR",
|
||||
"finding": "Missing decoupling",
|
||||
"why": "Datasheet requires 100n close to VDD",
|
||||
"status": "ERROR",
|
||||
"reference": "p.12",
|
||||
}
|
||||
f = Finding.model_validate(raw)
|
||||
assert f.net is None
|
||||
assert f.pins == []
|
||||
assert f.rule_id is None
|
||||
assert f.cad_sheet is None
|
||||
assert f.cad_uuid is None
|
||||
assert f.variant is None
|
||||
|
||||
|
||||
def test_new_fields_round_trip_json():
|
||||
f = Finding(
|
||||
designator="U1",
|
||||
mpn="SPX3819",
|
||||
finding="Cin too far",
|
||||
status="WARNING",
|
||||
net="VIN",
|
||||
pins=["U1.1", "C1.1"],
|
||||
rule_id="PS-DEC-001",
|
||||
cad_sheet="power.kicad_sch",
|
||||
cad_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
variant="DNP",
|
||||
)
|
||||
dumped = json.loads(f.model_dump_json())
|
||||
again = Finding.model_validate(dumped)
|
||||
assert again.net == "VIN"
|
||||
assert again.pins == ["U1.1", "C1.1"]
|
||||
assert again.rule_id == "PS-DEC-001"
|
||||
assert again.cad_sheet == "power.kicad_sch"
|
||||
assert again.cad_uuid == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
assert again.variant == "DNP"
|
||||
|
||||
|
||||
def test_unknown_extra_keys_do_not_break_legacy_payloads():
|
||||
f = Finding.model_validate(
|
||||
{
|
||||
"designator": "R1",
|
||||
"finding": "ok",
|
||||
"status": "INFO",
|
||||
"future_field_from_old_report": True,
|
||||
}
|
||||
)
|
||||
assert f.designator == "R1"
|
||||
|
||||
|
||||
def test_invalid_status_is_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
Finding(designator="U1", finding="x", status="error")
|
||||
|
||||
|
||||
def test_pins_must_be_a_list_not_a_string():
|
||||
with pytest.raises(ValidationError):
|
||||
Finding(designator="U1", finding="x", status="INFO", pins="U1.1")
|
||||
|
||||
|
||||
def test_status_ok_is_not_silently_accepted():
|
||||
with pytest.raises(ValidationError):
|
||||
Finding(designator="U1", finding="x", status="OK")
|
||||
@@ -114,3 +114,225 @@ def test_kicad_mpn_fills_empty_bom(tmp_path: Path):
|
||||
)
|
||||
assert g.components["U1"].mpn == "MSPM0G3507SPTR"
|
||||
assert "GND" in g.nets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hierarchical .kicad_sch (root + child sheets)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LIB_R = """
|
||||
(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))))
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
|
||||
return f"""
|
||||
(symbol
|
||||
(lib_id "Device:R")
|
||||
(at {x} {y} 0)
|
||||
(unit 1)
|
||||
(uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
|
||||
(pin "1" (uuid "p1"))
|
||||
(pin "2" (uuid "p2"))
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _sch(*body: str) -> str:
|
||||
return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n"
|
||||
|
||||
|
||||
def test_parse_single_sheet_kicad_sch(tmp_path: Path):
|
||||
from backend.pinscopex.parsers import parse_netlist_any
|
||||
|
||||
p = tmp_path / "one.kicad_sch"
|
||||
p.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
""",
|
||||
))
|
||||
parts, nets, fmt = parse_netlist_any(p)
|
||||
assert fmt == "kicad_sch"
|
||||
assert "R1" in parts
|
||||
assert ("R1", "1") in nets["GND"]
|
||||
|
||||
|
||||
def test_hierarchical_global_gnd_merges_across_sheets(tmp_path: Path):
|
||||
from backend.pinscopex.parsers import parse_netlist_any
|
||||
|
||||
child = tmp_path / "child.kicad_sch"
|
||||
child.write_text(_sch(
|
||||
_resistor("C1", "100n"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||
""",
|
||||
))
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
_parts, nets, fmt = parse_netlist_any(root)
|
||||
assert fmt == "kicad_sch"
|
||||
gnd = nets["GND"]
|
||||
assert ("R1", "1") in gnd
|
||||
assert ("C1", "1") in gnd
|
||||
|
||||
|
||||
def test_hierarchical_label_connects_through_sheet_pin(tmp_path: Path):
|
||||
from backend.pinscopex.parsers import parse_netlist_any
|
||||
|
||||
child = tmp_path / "analog.kicad_sch"
|
||||
child.write_text(_sch(
|
||||
_resistor("C1", "100n"),
|
||||
"""
|
||||
(hierarchical_label "VIN" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||
""",
|
||||
))
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(wire (pts (xy 0 3.81) (xy 50 3.81)))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(pin "VIN" unspecified (at 50 3.81 180) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"))
|
||||
)
|
||||
""",
|
||||
))
|
||||
_parts, nets, _fmt = parse_netlist_any(root)
|
||||
vin = nets["VIN"]
|
||||
assert ("R1", "1") in vin
|
||||
assert ("C1", "1") in vin
|
||||
|
||||
|
||||
def test_local_labels_same_name_do_not_merge_across_sheets(tmp_path: Path):
|
||||
from backend.pinscopex.parsers import parse_netlist_any
|
||||
|
||||
child = tmp_path / "child.kicad_sch"
|
||||
child.write_text(_sch(
|
||||
_resistor("R2", "1k"),
|
||||
"""
|
||||
(label "FOO" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||
""",
|
||||
))
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(label "FOO" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
_parts, nets, _fmt = parse_netlist_any(root)
|
||||
r1_nets = [n for n, pins in nets.items() if ("R1", "1") in pins]
|
||||
r2_nets = [n for n, pins in nets.items() if ("R2", "1") in pins]
|
||||
assert len(r1_nets) == 1 and len(r2_nets) == 1
|
||||
assert r1_nets[0] != r2_nets[0]
|
||||
assert "FOO" not in nets or len(nets.get("FOO", [])) <= 1
|
||||
|
||||
|
||||
def test_sheetfile_parent_traversal_is_rejected(tmp_path: Path):
|
||||
import pytest
|
||||
from backend.pinscopex.parsers_kicad import parse_kicad
|
||||
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Escape" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "../outside.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
with pytest.raises(ValueError, match="rejected"):
|
||||
parse_kicad(root)
|
||||
|
||||
|
||||
def test_missing_child_sheet_raises(tmp_path: Path):
|
||||
import pytest
|
||||
from backend.pinscopex.parsers_kicad import parse_kicad
|
||||
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Missing" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "nope.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
with pytest.raises(ValueError, match="Missing"):
|
||||
parse_kicad(root)
|
||||
|
||||
|
||||
def test_cyclic_sheet_include_is_rejected(tmp_path: Path):
|
||||
import pytest
|
||||
from backend.pinscopex.parsers_kicad import parse_kicad
|
||||
|
||||
child = tmp_path / "child.kicad_sch"
|
||||
child.write_text(_sch(
|
||||
_resistor("C1", "100n"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Root" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "root.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
root = tmp_path / "root.kicad_sch"
|
||||
root.write_text(_sch(
|
||||
_resistor("R1", "10k"),
|
||||
"""
|
||||
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||
(sheet
|
||||
(at 50 0)
|
||||
(size 20 20)
|
||||
(property "Sheetname" "Child" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
(property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
""",
|
||||
))
|
||||
with pytest.raises(ValueError, match="[Cc]yclic"):
|
||||
parse_kicad(root)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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.pinscopex.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)
|
||||
|
||||
|
||||
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 == []
|
||||
|
||||
|
||||
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"]
|
||||
@@ -50,6 +50,36 @@ def test_serialize_for_prompt_shows_reviewer_severity():
|
||||
assert [row["reviewer_severity"] for row in parsed] == ["ERROR", "INFO"]
|
||||
|
||||
|
||||
def test_normalize_passthrough_keeps_cad_fields():
|
||||
originals = [
|
||||
Finding(
|
||||
designator="U3",
|
||||
mpn="X",
|
||||
finding="finding 1",
|
||||
why="w",
|
||||
status="WARNING",
|
||||
recommendation="",
|
||||
source_page=1,
|
||||
reference="",
|
||||
net="UART5_TX",
|
||||
pins=["U3.54"],
|
||||
rule_id="PS-MUX-001",
|
||||
cad_sheet="mcu.kicad_sch",
|
||||
)
|
||||
]
|
||||
raw_findings = [{
|
||||
"merged_from": [1], "finding": "finding 1", "why": "w",
|
||||
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
|
||||
}]
|
||||
built = _build_normalized(raw_findings, [], originals)
|
||||
assert built is not None
|
||||
kept, _ = built
|
||||
assert kept[0].rule_id == "PS-MUX-001"
|
||||
assert kept[0].net == "UART5_TX"
|
||||
assert kept[0].pins == ["U3.54"]
|
||||
assert kept[0].cad_sheet == "mcu.kicad_sch"
|
||||
|
||||
|
||||
def test_schema_exposes_dropped_array_and_single_fix_field():
|
||||
props = SUBMIT_NORMALIZED_SCHEMA.input_schema["properties"]
|
||||
assert "dropped" in props
|
||||
|
||||
@@ -104,6 +104,8 @@ def test_i2c_missing_pullup():
|
||||
findings = check_i2c_pullups(g, cons)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].source == "i2c_pullup_check"
|
||||
assert findings[0].rule_id == "PS-I2C-001"
|
||||
assert findings[0].net == "I2C_SDA"
|
||||
|
||||
|
||||
def test_i2c_pullup_present():
|
||||
|
||||
@@ -69,6 +69,9 @@ def test_real_defect_uart5_swapped_is_error():
|
||||
assert {f.designator for f in findings} == {"U3"}
|
||||
tx = next(f for f in findings if "MCU-UART5-TX" in f.finding)
|
||||
assert "cannot be muxed as UART5_TX" in tx.finding
|
||||
assert tx.rule_id == "PS-MUX-001"
|
||||
assert tx.net == "MCU-UART5-TX"
|
||||
assert tx.pins == ["U3.54"]
|
||||
|
||||
|
||||
def test_correct_assignment_no_finding():
|
||||
|
||||
Reference in New Issue
Block a user