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:
2026-09-10 21:54:37 +02:00
co-authored by Cursor
parent d31c04ba86
commit e4fd6c3849
29 changed files with 1995 additions and 46 deletions
+107
View File
@@ -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"
+24
View File
@@ -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."""
+104
View File
@@ -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)
+84
View File
@@ -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")
+222
View File
@@ -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)
+160
View File
@@ -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"]
+30
View File
@@ -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
+2
View File
@@ -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():
+3
View File
@@ -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():