Files
periscope/tests/test_pcb_review.py
T
michele f3f96ea4d1 Treat KiCad /net vs schematic net as one hierarchy finding.
Pad-net compare strips leading slash and nested sheet path so /ESP32_EN
matches ESP32_EN. If every mismatch is only that prefix, emit PE-LAY-003
once instead of per-pad reconnect errors.
2026-09-20 08:10:34 +02:00

320 lines
11 KiB
Python

"""PCB review checks, inventory, report merge — no invented millimetres."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementDomain, PlacementIcGroup
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
Net,
NetType,
)
from backend.periscopex.pcb_checks import (
assign_pcb_finding_ids,
merge_schema_pcb_reports,
run_pcb_checks,
)
from backend.periscopex.pcb_inventory import build_pcb_inventory
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.pcb_review import build_pcb_layout_context
from backend.services.projects import ProjectMeta, STATUS_QUEUED, STATUS_RUNNING
SIMPLE = Path(__file__).resolve().parents[1] / "simple_project"
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text()
)
def test_pad_net_mismatch_is_pe_lay_001():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "GND"},
),
},
nets={
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert len(findings) == 1
assert findings[0].rule_id == "PE-LAY-001"
assert findings[0].status == "ERROR"
assert findings[0].recommendation
assert "Reconnect pad" in findings[0].recommendation
def test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad():
"""PCB /X vs schematic X (Emmaforo table) — one PE-LAY-003, pads treated as same net."""
from backend.periscopex.pcb_net_match import kicad_nets_match
pairs = [
("/ESP32_EN", "ESP32_EN"),
("/ESP_32_BOOT", "ESP_32_BOOT"),
("/REGN", "REGN"),
("/LED_SDATA", "LED_SDATA"),
("/power/REGN", "REGN"),
]
for pcb, sch in pairs:
assert kicad_nets_match(pcb, sch), (pcb, sch)
assert not kicad_nets_match("/sheet1/CLK", "/sheet2/CLK")
assert not kicad_nets_match("/ESP32_EN", "ESP32_BOOT")
pins = {
"1": "ESP32_EN",
"2": "ESP_32_BOOT",
"3": "REGN",
"4": "LED_SDATA",
}
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="ESP",
pins=pins,
),
},
nets={n: Net(name=n, net_type=NetType.SIGNAL, pins=[]) for n in pins.values()},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
LayoutPad(number="2", x=1, y=0, net="/ESP_32_BOOT"),
LayoutPad(number="3", x=2, y=0, net="/REGN"),
LayoutPad(number="4", x=3, y=0, net="/LED_SDATA"),
],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert [f.rule_id for f in findings] == ["PE-LAY-003"]
assert findings[0].status == "INFO"
assert "individual pads" in findings[0].recommendation.lower() or "Do not retouch" in findings[0].recommendation
assert "Won't fix" not in findings[0].recommendation
assert "Reconnect pad" not in findings[0].recommendation
def test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "ESP32_EN", "2": "GND"},
),
},
nets={
"ESP32_EN": Net(name="ESP32_EN", net_type=NetType.SIGNAL, pins=[]),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
LayoutPad(number="2", x=1, y=0, net="+3V3"),
],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert [f.rule_id for f in findings] == ["PE-LAY-001"]
assert findings[0].pins == ["2"]
assert "+3V3" in findings[0].finding
def test_missing_footprint_is_pe_lay_002():
graph = DesignGraph(
components={
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "GND"},
),
},
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
)
layout = LayoutGraph(footprints={
"U9": LayoutFootprint(reference="U9", x=0, y=0, layer="F.Cu"),
})
findings = check_pcb_net_match(graph, layout)
assert any(f.rule_id == "PE-LAY-002" for f in findings)
assert all(f.recommendation for f in findings)
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
findings = run_pcb_checks(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=10.0),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert plc
assert plc[0].finding_id.startswith("PCB-")
assert plc[0].recommendation
def test_close_decoupling_has_no_plc_001():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
findings = run_pcb_checks(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=0.5),
)
assert all(f.rule_id != "PE-PLC-001" for f in findings)
def test_inventory_lists_net_length_and_pair():
layout = LayoutGraph(
nets={"USB_DP": 1, "USB_DM": 2},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_DP"),
LayoutSegment(start=(0, 1), end=(8, 1), width=0.2, layer="F.Cu", net="USB_DM"),
],
)
inv = build_pcb_inventory(layout)
names = {n.name: n for n in inv.nets}
assert names["USB_DP"].length_mm == 10.0
assert names["USB_DP"].pair == "USB_DM"
def test_layout_context_includes_domain_and_group():
graph = _graph()
plan = FunctionalGroupsReport(
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
groups=[PlacementIcGroup(ref="U3", satellites=[])],
)
layout = LayoutGraph(
footprints={"U3": LayoutFootprint(reference="U3", x=1, y=2, layer="F.Cu")},
)
text = build_pcb_layout_context("U3", graph, layout, plan)
assert "Domain: 3v3" in text
assert "Functional group: U3" in text
assert "Footprint U3" in text
def test_merge_reports_prefixes_do_not_collide():
schema = {"findings": [{"finding_id": "U3-001", "status": "WARNING"}]}
pcb = {"findings": [{"finding_id": "PCB-U3-001", "status": "ERROR"}]}
merged = merge_schema_pcb_reports(schema, pcb)
ids = [f["finding_id"] for f in merged["findings"]]
assert ids == ["U3-001", "PCB-U3-001"]
assert merged["summary"]["ERROR"] == 1
assert merged["summary"]["WARNING"] == 1
def test_assign_fills_empty_recommendation():
from backend.periscopex.models import Finding
f = Finding(
designator="U1", mpn="", finding="x", why="y", status="INFO",
recommendation="",
)
assign_pcb_finding_ids([f])
assert f.finding_id == "PCB-U1-001"
assert f.recommendation
def test_pcb_busy_helpers():
active = frozenset({"queued", "running"})
analysis = frozenset({STATUS_QUEUED, STATUS_RUNNING})
draft = ProjectMeta(id="p", name="t", created="2026-01-01", user_id="u")
assert (draft.pcb_status or "draft") not in active
draft.pcb_status = "queued"
assert (draft.pcb_status or "draft") in active
draft.status = STATUS_RUNNING
assert draft.status in analysis
def test_heal_dead_analysis_worker_unblocks_pcb(tmp_path: Path, monkeypatch):
from backend.services import job_runner, projects as proj_svc
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
meta = proj_svc.create_project(storage, "usr_jwt", "board")
proj_svc.update_project(
storage, "usr_jwt", meta.id,
status=STATUS_RUNNING,
has_pcb=True,
has_bom=True,
has_netlist=True,
pcb_status="draft",
)
monkeypatch.setattr(job_runner, "get_execution_state", lambda _n: "failed")
healed = proj_svc.heal_if_pipeline_finished(storage, "usr_jwt", meta.id)
assert healed is not None
assert healed.status == "error"
from backend.services.pcb_pipeline import analysis_busy
assert not analysis_busy(healed)
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": "noboard"}).json()["id"]
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
assert resp.status_code == 400
assert "kicad_pcb" in resp.json()["detail"]
def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
"""JWT owner path with JSON still saying user_id=local (live Emmaforo)."""
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
pid = "759a4dea9612"
owner = "usr_jwt_owner"
storage.write_json(
f"users/{owner}/projects/{pid}/project.json",
{
"id": pid,
"name": "Emmaforo",
"user_id": "local",
"created": "2026-01-01T00:00:00Z",
"status": "complete",
"has_pcb": True,
"has_bom": True,
"has_netlist": True,
"pcb_status": "draft",
},
)
from backend.services import projects as proj_svc
proj_svc.update_project(storage, owner, pid, pcb_status="queued")
jwt_meta = proj_svc.get_project(storage, owner, pid)
assert jwt_meta is not None
assert jwt_meta.pcb_status == "queued"
assert proj_svc.get_project(storage, "local", pid) is None