update_project used meta.user_id for the path, so JWT projects that still have user_id=local never persisted pcb_status=queued and the PCB worker exited as draft. Point writes at the prefix used to read the project.
214 lines
6.9 KiB
Python
214 lines
6.9 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
|
|
|
|
|
|
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_pcb_start_requires_board(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": "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
|