"""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"]