Make the component library a standalone product door (2.63.0).

Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
2026-09-21 22:12:00 +02:00
parent 20733f0ec6
commit 4df04df5d4
101 changed files with 2060 additions and 302 deletions
View File
+107
View File
@@ -0,0 +1,107 @@
"""BOM vs schematic MPN/value match.
Favor: identical MPNs silent; real mismatch is ERROR PE-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.periscopex.bom_match_check import check_bom_schematic_match
from backend.periscopex.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 == "PE-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 == "PE-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.periscopex.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 == "PE-BOM-001"
assert findings[0].designator == "U1"
+31
View File
@@ -0,0 +1,31 @@
"""KiCad BOM: Value/PNM as MPN when Manufacturer Part Number is empty."""
from pathlib import Path
from backend.periscopex.parsers import parse_bom
def test_parse_bom_uses_value_for_ic_when_mpn_column_empty(tmp_path: Path):
csv = tmp_path / "bom.csv"
csv.write_text(
"Reference,Qty,Value,DNP,Footprint,Datasheet,PNM\n"
"U1,1,TPS22965DSGR,,SON-8,,\n"
'"U9,U13",5,TPD2E007DCKR,,SOT-23,,\n'
"C1,1,100nF,,0805,,\n"
)
bom = parse_bom(csv, mpn_col="Manufacturer Part Number")
assert bom["U1"]["mpn"] == "TPS22965DSGR"
assert bom["U9"]["mpn"] == "TPD2E007DCKR"
assert bom["U13"]["mpn"] == "TPD2E007DCKR"
assert bom["C1"]["mpn"] is None
def test_parse_bom_keeps_pnm_and_datasheet_url(tmp_path: Path):
csv = tmp_path / "bom.csv"
csv.write_text(
"Reference,Value,PNM,Datasheet\n"
"U31,TMP117,TMP117NAIDRVR,https://www.ti.com/lit/ds/symlink/tmp117.pdf\n"
)
bom = parse_bom(csv, mpn_col="Manufacturer Part Number")
assert bom["U31"]["mpn"] == "TMP117NAIDRVR"
assert bom["U31"]["datasheet_url"].endswith("tmp117.pdf")
+99
View File
@@ -0,0 +1,99 @@
"""Crystal CL check — only fires when CL and cap values are known."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from backend.periscopex.crystal_cl_check import check_crystal_cl
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
SimpleComponentSpecs,
)
def _xtal_graph(*, cl_f: float | None, c1: str, c2: str, stray: float | None = None) -> DesignGraph:
values: dict = {}
if cl_f is not None:
values["load_capacitance_f"] = cl_f
if stray is not None:
values["stray_capacitance_f"] = stray
return DesignGraph(
components={
"X1": Component(
reference="X1",
value="8MHz",
footprint="",
component_type=ComponentType.CRYSTAL,
mpn="XTAL",
pins={"1": "XIN", "2": "XOUT", "3": "GND"},
specs=SimpleComponentSpecs(specs_type="crystal", values=values) if values else None,
),
"C1": Component(
reference="C1", value=c1, footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "XIN", "2": "GND"},
),
"C2": Component(
reference="C2", value=c2, footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "XOUT", "2": "GND"},
),
},
nets={
"XIN": Net(
name="XIN", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="X1", pin_number="1"),
PinConnection(component_ref="C1", pin_number="1"),
],
),
"XOUT": Net(
name="XOUT", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="X1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="1"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="X1", pin_number="3"),
PinConnection(component_ref="C1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="2"),
],
),
},
)
def test_no_cl_in_specs_is_silent():
g = _xtal_graph(cl_f=None, c1="18p", c2="18p")
assert check_crystal_cl(g) == []
def test_series_above_cl_without_stray_warns():
# series of 22p || 22p = 11p — wait we need series > CL.
# 100p || 100p = 50p > CL 18p * 1.25
g = _xtal_graph(cl_f=18e-12, c1="100p", c2="100p")
findings = check_crystal_cl(g)
assert any(f.rule_id == "PE-XTAL-002" for f in findings)
def test_with_stray_mismatch_warns():
# 18p||18p = 9p + 2p stray = 11p vs CL 18p → below 0.75*18
g = _xtal_graph(cl_f=18e-12, c1="18p", c2="18p", stray=2e-12)
findings = check_crystal_cl(g)
assert any(f.rule_id == "PE-XTAL-002" for f in findings)
def test_simple_project_without_cl_silent():
from pathlib import Path
from backend.periscopex.models import DesignGraph
path = SIMPLE_PROJECT / "design_graph.json"
g = DesignGraph.model_validate_json(path.read_text())
assert check_crystal_cl(g) == []
+197
View File
@@ -0,0 +1,197 @@
"""Cross-IC dedup pass — schema + validator behavior.
Locks in the rules for collapsing one physical interface defect reported from
both ICs (the U2-001 / U3-001 duplication) into a single finding:
1. Singletons pass through unchanged (no laundering).
2. A merge uses the `primary_index` member for component attribution + source,
and its severity is clamped to the strongest member (never upgraded).
3. `Unverified:` members cap the merge at WARNING and keep the prefix.
4. Full index coverage is required; a missing/invalid `primary_index` un-merges
rather than dropping findings.
"""
from __future__ import annotations
import json
from backend.periscopex.models import Finding
from backend.services.dedupe_findings import (
SUBMIT_DEDUPED_SCHEMA,
_build_deduped,
_serialize_findings_for_prompt,
)
def _f(idx: int, designator: str = "U2", status: str = "ERROR",
why: str = "w", source_page: int | None = None) -> Finding:
return Finding(
designator=designator,
mpn=f"MPN{idx}",
finding=f"finding {idx}",
why=why,
status=status,
recommendation="",
source_page=source_page if source_page is not None else idx,
source_quote=f"quote {idx}",
reference=f"ref {idx}",
)
def test_serialize_includes_designator_and_severity():
"""The IC + reviewer severity are the primary signals for spotting that
two findings are the two ends of one interface."""
out = _serialize_findings_for_prompt([_f(1, "U2", "ERROR"), _f(2, "U3", "WARNING")])
parsed = json.loads(out)
assert [r["ic"] for r in parsed] == ["U2", "U3"]
assert [r["reviewer_severity"] for r in parsed] == ["ERROR", "WARNING"]
def test_schema_requires_member_indices():
item = SUBMIT_DEDUPED_SCHEMA.input_schema["properties"]["groups"]["items"]
assert "member_indices" in item["required"]
assert "primary_index" in item["properties"]
def test_singletons_pass_through_unchanged():
originals = [_f(1, "U2"), _f(2, "U3")]
groups = [
{"member_indices": [1], "change_rationale": "passthrough"},
{"member_indices": [2], "change_rationale": "passthrough"},
]
built = _build_deduped(groups, originals)
assert built is not None
assert [f.finding for f in built] == ["finding 1", "finding 2"]
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="PE-USB-001",
)
]
groups = [{"member_indices": [1], "change_rationale": "passthrough"}]
built = _build_deduped(groups, originals)
assert built is not None
assert built[0].rule_id == "PE-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."""
originals = [
_f(1, "U2", "ERROR", source_page=11),
_f(2, "U3", "ERROR", source_page=25),
]
groups = [{
"member_indices": [1, 2],
"primary_index": 2,
"finding": "CH340E 5V output into MCU non-5V-tolerant pin",
"why": "abs-max exceeded on the UART interface",
"status": "ERROR",
"recommendation": "level shift",
"change_rationale": "merged 1+2: same UART interface",
}]
built = _build_deduped(groups, originals)
assert built is not None
assert len(built) == 1
f = built[0]
assert f.designator == "U3" # from primary_index=2
assert f.source_page == 25 # primary's citation
assert f.source_quote == "quote 2" # primary's quote retained
assert f.status == "ERROR"
assert "CH340E" in f.finding
def test_merge_severity_clamped_to_strongest_member():
originals = [_f(1, "U2", "WARNING"), _f(2, "U3", "INFO")]
groups = [{
"member_indices": [1, 2],
"primary_index": 1,
"finding": "merged",
"why": "combined",
"status": "ERROR", # over-graded — clamp to WARNING
"recommendation": "",
"change_rationale": "merged",
}]
built = _build_deduped(groups, originals)
assert built is not None
assert built[0].status == "WARNING"
def test_merge_with_unverified_member_caps_at_warning_and_keeps_prefix():
originals = [
_f(1, "U2", "WARNING", why="Unverified: abs-max for PA14 not confirmed"),
_f(2, "U3", "ERROR", why="will damage the MCU"),
]
groups = [{
"member_indices": [1, 2],
"primary_index": 2,
"finding": "merged overvoltage",
"why": "5V into a 3.6V pin",
"status": "ERROR",
"recommendation": "",
"change_rationale": "merged",
}]
built = _build_deduped(groups, originals)
assert built is not None
assert built[0].status == "WARNING" # unverified caps the merge
assert built[0].why.lower().startswith("unverified:")
def test_invalid_primary_index_unmerges_to_originals():
originals = [_f(1, "U2", "ERROR"), _f(2, "U3", "WARNING")]
groups = [{
"member_indices": [1, 2],
"primary_index": 5, # not a member → un-merge
"finding": "merged",
"why": "x",
"status": "ERROR",
"recommendation": "",
"change_rationale": "merged",
}]
built = _build_deduped(groups, originals)
assert built is not None
assert len(built) == 2
assert [f.status for f in built] == ["ERROR", "WARNING"] # originals intact
def test_missing_primary_index_on_merge_unmerges():
originals = [_f(1, "U2"), _f(2, "U3")]
groups = [{
"member_indices": [1, 2],
"finding": "merged", "why": "x", "status": "ERROR",
"recommendation": "", "change_rationale": "merged",
}]
built = _build_deduped(groups, originals)
assert built is not None
assert len(built) == 2
def test_coverage_gap_is_rejected():
originals = [_f(1), _f(2)]
groups = [{"member_indices": [1], "change_rationale": "passthrough"}]
assert _build_deduped(groups, originals) is None
def test_duplicate_index_is_rejected():
originals = [_f(1), _f(2)]
groups = [
{"member_indices": [1], "change_rationale": "p"},
{"member_indices": [1, 2], "primary_index": 2, "finding": "m",
"why": "x", "status": "INFO", "recommendation": "", "change_rationale": "m"},
]
assert _build_deduped(groups, originals) is None
+109
View File
@@ -0,0 +1,109 @@
"""DC-bias C_eff stima — not a Murata lot curve.
Favor: C0G stays at C; X7R at 50% Vr loses ~30%; C_eff formatted.
Against: tantalum uses C_nom (no MLCC model); missing Vr or C skips C_eff;
C0G is not treated as X7R.
"""
from __future__ import annotations
from backend.periscopex.derating import (
build_derating_table,
dc_bias_remaining,
)
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
def test_c0g_keeps_full_capacitance():
assert dc_bias_remaining("C0G", v_op=16.0, rated_v=16.0) == 1.0
assert dc_bias_remaining("NP0", v_op=10.0, rated_v=16.0) == 1.0
def test_x7r_at_half_rated_is_about_70_percent():
f = dc_bias_remaining("X7R", v_op=8.0, rated_v=16.0)
assert f is not None
assert 0.65 <= f <= 0.75
def test_x7r_at_zero_bias_is_nominal():
assert dc_bias_remaining("X7R", v_op=0.0, rated_v=16.0) == 1.0
def test_tantalum_has_no_mlcc_bias_model():
assert dc_bias_remaining("tantalum", v_op=8.0, rated_v=16.0) is None
def test_missing_voltage_or_value_skips_c_eff():
assert dc_bias_remaining("X7R", v_op=None, rated_v=16.0) is None
assert dc_bias_remaining("X7R", v_op=8.0, rated_v=None) is None
def _cap(ref, dielectric, farads, rated, net="3V3"):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.CAPACITOR,
component_subtype="passive.capacitor.ceramic",
mpn=ref,
pins={"1": net, "2": "GND"},
specs=CapacitorSpecs(
value_farads=farads,
value_formatted="10uF",
voltage_rating_v=f"{rated}V",
dielectric=dielectric,
),
)
def test_derating_row_includes_c_eff_stima():
c1 = _cap("C1", "X7R", 10e-6, 16)
g = DesignGraph(
components={
"C1": c1,
},
nets={
"3V3": Net(
name="3V3", net_type=NetType.POWER, voltage=8.0,
pins=[PinConnection(component_ref="C1", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND, voltage=0.0,
pins=[PinConnection(component_ref="C1", pin_number="2")],
),
},
)
rows = build_derating_table(g)
assert len(rows) == 1
row = rows[0]
assert row["dc_bias_model"] == "stima"
assert row["c_nominal_f"] == 10e-6
assert row["c_eff_f"] is not None
assert 6.5e-6 <= row["c_eff_f"] <= 7.5e-6
assert "uF" in (row["c_eff_formatted"] or "")
def test_c0g_row_c_eff_equals_nominal():
c1 = _cap("C9", "C0G", 18e-12, 50)
g = DesignGraph(
components={"C9": c1},
nets={
"3V3": Net(
name="3V3", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="C9", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[PinConnection(component_ref="C9", pin_number="2")],
),
},
)
row = build_derating_table(g)[0]
assert row["c_eff_f"] == 18e-12
assert row["dc_bias_factor"] == 1.0
+130
View File
@@ -0,0 +1,130 @@
"""DNP variant: fitted enable without pull/driver is ERROR."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.dnp_check import check_dnp_enables
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.parsers import parse_bom
def _graph(components, nets, bom_fields=None):
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=components, nets=net_objs, bom_fields=bom_fields or {})
def _cons():
return {
"LDOX": ComponentConstraints(
mpn="LDOX",
pintable=[
Pin(number=1, name="VIN"),
Pin(number=2, name="VOUT"),
Pin(number=3, name="EN"),
Pin(number=4, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
)
}
def _ldo():
return Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="LDOX",
pins={"1": "VIN", "2": "VOUT", "3": "EN_NET", "4": "GND"},
)
def _r(dnp_net="EN_NET"):
return Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": dnp_net, "2": "VIN"},
specs=ResistorSpecs(value_ohms=10000, value_formatted="10k"),
)
def test_dnp_pull_leaves_enable_floating():
g = _graph(
{"U1": _ldo(), "R1": _r()},
{
"VIN": (NetType.POWER, [("U1", "1"), ("R1", "2")]),
"VOUT": (NetType.POWER, [("U1", "2")]),
"EN_NET": (NetType.SIGNAL, [("U1", "3"), ("R1", "1")]),
"GND": (NetType.GROUND, [("U1", "4")]),
},
bom_fields={
"U1": {"mpn": "LDOX", "value": "", "dnp": False},
"R1": {"mpn": "R1", "value": "10k", "dnp": True},
},
)
findings = check_dnp_enables(g, _cons())
assert len(findings) == 1
assert findings[0].rule_id == "PE-DNP-001"
assert findings[0].status == "ERROR"
def test_fitted_pull_is_silent():
g = _graph(
{"U1": _ldo(), "R1": _r()},
{
"VIN": (NetType.POWER, [("U1", "1"), ("R1", "2")]),
"VOUT": (NetType.POWER, [("U1", "2")]),
"EN_NET": (NetType.SIGNAL, [("U1", "3"), ("R1", "1")]),
"GND": (NetType.GROUND, [("U1", "4")]),
},
bom_fields={
"U1": {"mpn": "LDOX", "value": "", "dnp": False},
"R1": {"mpn": "R1", "value": "10k", "dnp": False},
},
)
assert check_dnp_enables(g, _cons()) == []
def test_no_dnp_column_skips_floating_enable():
g = _graph(
{"U1": _ldo()},
{
"VIN": (NetType.POWER, [("U1", "1")]),
"VOUT": (NetType.POWER, [("U1", "2")]),
"EN_NET": (NetType.SIGNAL, [("U1", "3")]),
"GND": (NetType.GROUND, [("U1", "4")]),
},
bom_fields={"U1": {"mpn": "LDOX", "value": ""}},
)
assert check_dnp_enables(g, _cons()) == []
def test_parse_bom_reads_dnp_and_skips_when_column_absent(tmp_path: Path):
with_dnp = tmp_path / "dnp.csv"
with_dnp.write_text(
"Reference,Value,DNP,Manufacturer Part Number\n"
"U1,LDO,,LDOX\n"
"R1,10k,1,Rpull\n"
)
bom = parse_bom(with_dnp)
assert bom["U1"]["dnp"] is False
assert bom["R1"]["dnp"] is True
no_col = tmp_path / "plain.csv"
no_col.write_text("Reference,Value,Manufacturer Part Number\nU1,LDO,LDOX\n")
bom2 = parse_bom(no_col)
assert "dnp" not in bom2["U1"]
+179
View File
@@ -0,0 +1,179 @@
"""EDIF 2.0.0 netlist parser — Siemens xDX Designer flavor.
Verified against the client-supplied file ``edif-files/144040 (1).edn``
(128 KB, two sub-designs merged, 42 instances, 20 nets). The BOM at
``edif-files/BOM (1).xlsx`` covers 19 of those 42 designators; the rest are
orphan parts in the second sub-design and are expected to land in the graph
unchanged.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.periscopex.parsers import (
detect_netlist_format,
parse_netlist_any,
validate_netlist,
)
from backend.periscopex.parsers_edif import (
list_edif_subdesigns,
parse_edif_netlist,
)
EDIF_FIXTURE = Path(__file__).resolve().parent.parent / "edif-files" / "144040 (1).edn"
# The client-supplied EDIF sample is confidential and never committed —
# these tests only run on machines that have it locally.
if not EDIF_FIXTURE.exists():
pytest.skip(
"edif-files/ sample netlist not present (local-only, untracked)",
allow_module_level=True,
)
# Designators the client's BOM lists. The parser MUST surface every one of
# these — anything else is a regression.
BOM_DESIGNATORS = frozenset({
"C1", "C2", "C3", "C4", "C5", "C6",
"C22", "C23", "C24", "C25",
"R1", "R6", "R7",
"L1", "L5",
"FB1", "FB2",
"U1", "U3",
})
@pytest.fixture(scope="module")
def parsed():
parts, nets = parse_edif_netlist(EDIF_FIXTURE)
return parts, nets
def test_format_detection():
assert detect_netlist_format(EDIF_FIXTURE.read_bytes()) == "edif"
def test_dispatcher_routes_to_edif():
parts, nets, fmt = parse_netlist_any(EDIF_FIXTURE)
assert fmt == "edif"
assert parts and nets
def test_all_bom_designators_present(parsed):
parts, _ = parsed
missing = BOM_DESIGNATORS - set(parts)
assert not missing, f"BOM designators missing from parser output: {sorted(missing)}"
def test_u3_pin_count_matches_ldo(parsed):
"""U3 is the MIC5305YMLTR LDO — datasheet has 7 pins (6 + thermal EPAD)."""
_, nets = parsed
u3_pins = {pin for conns in nets.values() for ref, pin in conns if ref == "U3"}
assert len(u3_pins) == 7, f"expected 7 distinct pins for U3, got {sorted(u3_pins)}"
def test_u1_pin_count_matches_rf_amp(parsed):
"""U1 is the CMD263P3 RF amp — datasheet has 17 pins (16 + thermal EPAD)."""
_, nets = parsed
u1_pins = {pin for conns in nets.values() for ref, pin in conns if ref == "U1"}
assert len(u1_pins) == 17, f"expected 17 distinct pins for U1, got {sorted(u1_pins)}"
def test_ground_net_renamed(parsed):
"""The parser must rename Pin_Type=GROUND nets to ``GND`` so the existing
validate_netlist() ground check passes."""
_, nets = parsed
assert "GND" in nets, f"expected a 'GND' net, found: {sorted(nets)}"
assert len(nets["GND"]) > 5, "GND should have many endpoints in a real design"
def test_validate_netlist_accepts_edif_output(parsed):
parts, nets = parsed
issues = validate_netlist(parts, nets)
assert issues == [], f"unexpected validation issues: {issues}"
def test_every_net_endpoint_resolves(parsed):
"""No dangling (ref, pin) tuples — every connection should reference a
designator that's also in the parts dict."""
parts, nets = parsed
refs = set(parts)
bad = [
(net, ref, pin)
for net, conns in nets.items()
for ref, pin in conns
if ref not in refs
]
assert not bad, f"net endpoints reference unknown designators: {bad[:5]}"
def test_template_designators_skipped(parsed):
"""Instances whose designator is still a template (``R?`` / ``C?`` / ``U?``)
should not leak into parts — they're unconfigured library symbols."""
parts, _ = parsed
leaked = [ref for ref in parts if ref.endswith("?")]
assert not leaked, f"template designators leaked: {leaked}"
# ---------------------------------------------------------------------------
# Sub-design listing + filtering
# ---------------------------------------------------------------------------
def test_list_subdesigns_finds_two():
"""The client's file has two sub-designs (&0441, &0442). The lister must
surface both with their instance counts and full designator lists."""
subs = list_edif_subdesigns(EDIF_FIXTURE)
ids = sorted(s["id"] for s in subs if s["id"])
assert ids == ["&0441", "&0442"], f"unexpected sub-design ids: {ids}"
# Each sub-design carries 21 resolved-designator instances.
for s in subs:
assert s["instance_count"] == len(s["designators"])
assert s["instance_count"] > 0
def test_subdesign_filter_keeps_only_selected():
"""Filtering to ``&0441`` must yield exactly that sub-design's parts."""
parts_a, nets_a = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0441"})
parts_b, nets_b = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0442"})
bom = BOM_DESIGNATORS
in_a = set(parts_a) & bom
in_b = set(parts_b) & bom
assert in_a == bom, f"sub-design A should hold every BOM ref; missing: {bom - in_a}"
assert in_b == set(), f"sub-design B has 0 BOM refs in the fixture; got: {in_b}"
# Designators must not overlap across sub-designs (xDX gives each its own
# ref namespace per board).
assert set(parts_a).isdisjoint(parts_b)
def test_subdesign_filter_preserves_shared_ground():
"""``GND`` (renamed via Pin_Type detection) spans both sub-designs in the
raw EDIF. After filtering to one sub-design the net survives, but only
with endpoints from instances that survived."""
_, nets_a = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns={"&0441"})
_, nets_all = parse_edif_netlist(EDIF_FIXTURE)
assert "GND" in nets_a
refs_a = {ref for ref, _ in nets_a["GND"]}
refs_all = {ref for ref, _ in nets_all["GND"]}
# Filtered GND is a strict subset of unfiltered GND.
assert refs_a < refs_all, "filtered GND should drop the excluded sub-design's endpoints"
def test_subdesign_filter_empty_set_returns_empty():
"""Empty selection yields no parts (and no nets that referenced them)."""
parts, nets = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns=set())
assert parts == {}
assert nets == {}
def test_subdesign_filter_none_matches_unfiltered():
"""``include_subdesigns=None`` (the default) is the pre-flag behavior."""
parts1, nets1 = parse_edif_netlist(EDIF_FIXTURE)
parts2, nets2 = parse_edif_netlist(EDIF_FIXTURE, include_subdesigns=None)
assert parts1 == parts2
assert nets1 == nets2
+94
View File
@@ -0,0 +1,94 @@
"""Errata catalog — workaround on the graph, no scraping.
Favor: known MPN with a pull-up workaround missing on the net → PE-ERRATA-001.
Against: MPN not in catalog is silent (even TI-looking); workaround pull-up
present is silent; catalog entry without url is skipped.
"""
from __future__ import annotations
from backend.periscopex.errata_check import check_errata
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
def _cons():
return {
"ERRX": ComponentConstraints(
mpn="ERRX",
pintable=[Pin(number=1, name="NRST"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
)
}
def _graph(with_pull: bool):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="ERRX",
pins={"1": "NRST", "2": "GND"},
)
comps = {"U1": u}
nets = {
"NRST": (NetType.SIGNAL, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
"3V3": (NetType.POWER, []),
}
if with_pull:
r = Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "NRST", "2": "3V3"},
specs=ResistorSpecs(value_ohms=10000, value_formatted="10k"),
)
comps["R1"] = r
nets["NRST"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
nets["3V3"] = (NetType.POWER, [("R1", "2")])
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=comps, nets=net_objs)
CATALOG = {
"ERRX": {
"url": "https://www.ti.com/lit/er/fixture",
"workarounds": [
{"kind": "pullup", "pin_name": "NRST", "note": "10 kΩ to VDD per errata"},
],
}
}
def test_missing_errata_pullup_is_ps_errata_001():
findings = check_errata(_graph(False), _cons(), CATALOG)
assert len(findings) == 1
assert findings[0].rule_id == "PE-ERRATA-001"
assert findings[0].status == "WARNING"
assert findings[0].source == "errata_check"
assert "ti.com/lit/er" in findings[0].reference
def test_pullup_present_is_silent():
assert check_errata(_graph(True), _cons(), CATALOG) == []
def test_unknown_mpn_and_url_less_entry_are_silent():
g = _graph(False)
g.components["U1"].mpn = "UNKNOWNPART"
assert check_errata(g, _cons(), CATALOG) == []
no_url = {"ERRX": {"url": "", "workarounds": [{"kind": "pullup", "pin_name": "NRST"}]}}
assert check_errata(_graph(False), _cons(), no_url) == []
+230
View File
@@ -0,0 +1,230 @@
"""Filter topology matcher — RC/LC/π/T fc, ADC compare only with specs."""
from __future__ import annotations
from backend.periscopex.filter_check import check_filters
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InductorSpecs,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
)
def _graph(components, nets):
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=components, nets=net_objs)
def _res(ref, ohms, n1, n2):
return Component(
reference=ref, value=str(ohms), footprint="",
component_type=ComponentType.RESISTOR, mpn=ref,
pins={"1": n1, "2": n2},
specs=ResistorSpecs(value_ohms=ohms, value_formatted=str(ohms)),
)
def _cap(ref, farads, net):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.CAPACITOR, mpn=ref,
pins={"1": net, "2": "GND"},
specs=CapacitorSpecs(value_farads=farads, value_formatted="x"),
)
def _l(ref, henries, n1, n2, ferrite=False, dcr=None):
sub = "passive.ferrite_bead" if ferrite else "passive.inductor"
kwargs = dict(value_formatted="x", component_subtype=sub, dcr_ohms=dcr)
if ferrite:
specs = InductorSpecs(impedance_ohm=100.0, value_henries=henries, **kwargs)
else:
specs = InductorSpecs(value_henries=henries, **kwargs)
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.INDUCTOR, mpn=ref,
component_subtype=sub,
pins={"1": n1, "2": n2}, specs=specs,
)
def _ic(ref="U1", pins=None, values=None, mpn="UTEST"):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.IC, mpn=mpn,
pins=pins or {"1": "AIN", "2": "GND"},
specs=SimpleComponentSpecs(
specs_type="ic", values=values or {},
) if values is not None else None,
)
def test_rc_reports_fc_info_without_adc_rate():
# 1k * 100nF -> fc ≈ 1.59 kHz; no sample rate → INFO not WARNING.
g = _graph(
{
"U1": _ic(),
"R1": _res("R1", 1e3, "AIN", "FILT"),
"C1": _cap("C1", 100e-9, "FILT"),
},
{
"AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]),
"FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
findings = check_filters(g)
assert len(findings) == 1
assert findings[0].rule_id == "PE-FLT-001"
assert findings[0].status == "INFO"
assert findings[0].source == "filter_check"
def test_rc_vs_adc_rate_is_warning():
g = _graph(
{
"U1": _ic(values={"adc_sample_rate": 1e6}),
"R1": _res("R1", 1e3, "AIN", "FILT"),
"C1": _cap("C1", 100e-9, "FILT"),
},
{
"AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]),
"FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
findings = check_filters(g)
assert len(findings) == 1
assert findings[0].rule_id == "PE-FLT-002"
assert findings[0].status == "WARNING"
def test_pullup_plus_decoupling_is_not_a_filter():
g = _graph(
{
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="UTEST",
pins={"1": "SDA", "2": "3V3", "3": "GND"},
),
"R1": _res("R1", 4700, "SDA", "3V3"),
"C1": _cap("C1", 100e-9, "3V3"),
},
{
"SDA": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]),
"3V3": (NetType.POWER, [("U1", "2"), ("R1", "2"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "3"), ("C1", "2")]),
},
)
assert check_filters(g) == []
def test_missing_c_value_does_not_invent_fc_warning():
c = Component(
reference="C1", value="", footprint="",
component_type=ComponentType.CAPACITOR, mpn="C1",
pins={"1": "FILT", "2": "GND"},
)
g = _graph(
{
"U1": _ic(values={"adc_sample_rate": 1e6}),
"R1": _res("R1", 1e3, "AIN", "FILT"),
"C1": c,
},
{
"AIN": (NetType.SIGNAL, [("U1", "1"), ("R1", "1")]),
"FILT": (NetType.SIGNAL, [("R1", "2"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
findings = check_filters(g)
assert len(findings) == 1
assert findings[0].rule_id == "PE-FLT-001"
assert findings[0].status == "INFO"
def test_pi_and_t_need_l_and_c():
g_pi = _graph(
{
"L1": _l("L1", 10e-6, "A", "B"),
"C1": _cap("C1", 100e-9, "A"),
"C2": _cap("C2", 100e-9, "B"),
},
{
"A": (NetType.SIGNAL, [("L1", "1"), ("C1", "1")]),
"B": (NetType.SIGNAL, [("L1", "2"), ("C2", "1")]),
"GND": (NetType.GROUND, [("C1", "2"), ("C2", "2")]),
},
)
pi = check_filters(g_pi)
assert len(pi) == 1 and pi[0].rule_id == "PE-FLT-001" and "π" in pi[0].finding
g_t = _graph(
{
"L1": _l("L1", 10e-6, "A", "MID"),
"L2": _l("L2", 10e-6, "MID", "B"),
"C1": _cap("C1", 100e-9, "MID"),
},
{
"A": (NetType.SIGNAL, [("L1", "1")]),
"MID": (NetType.SIGNAL, [("L1", "2"), ("L2", "1"), ("C1", "1")]),
"B": (NetType.SIGNAL, [("L2", "2")]),
"GND": (NetType.GROUND, [("C1", "2")]),
},
)
t = check_filters(g_t)
assert len(t) == 1 and "T" in t[0].finding
def test_ferrite_dcr_warns_only_with_datasheet_limit():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=1, name="VDDA"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
)
}
fb = _l("FB1", None, "VDDA", "3V3", ferrite=True, dcr=2.0)
g = _graph(
{
"U1": _ic(pins={"1": "VDDA", "2": "GND"}, values={}),
"FB1": fb,
"C1": _cap("C1", 100e-9, "VDDA"),
},
{
"VDDA": (NetType.POWER, [("U1", "1"), ("FB1", "1"), ("C1", "1")]),
"3V3": (NetType.POWER, [("FB1", "2")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
assert not any(f.rule_id == "PE-FLT-003" for f in check_filters(g, cons))
g2 = _graph(
{
"U1": _ic(pins={"1": "VDDA", "2": "GND"}, values={"max_ferrite_dcr_ohms": 0.5}),
"FB1": fb,
"C1": _cap("C1", 100e-9, "VDDA"),
},
{
"VDDA": (NetType.POWER, [("U1", "1"), ("FB1", "1"), ("C1", "1")]),
"3V3": (NetType.POWER, [("FB1", "2")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
dcr = [f for f in check_filters(g2, cons) if f.rule_id == "PE-FLT-003"]
assert len(dcr) == 1 and dcr[0].status == "WARNING"
+223
View File
@@ -0,0 +1,223 @@
"""Finding engine — FACT/REQUIREMENT/INFERENCE, provenance clamp, decisions."""
from __future__ import annotations
from backend.periscopex.finding_engine import (
DesignerDecision,
apply_decisions,
complete_finding,
finding_fingerprint,
lookup_rule,
)
from backend.periscopex.models import Finding
from backend.periscopex.review_parse import assign_finding_ids, parse_submit_review as _parse_review
def test_recommended_never_error():
f = Finding(
designator="U1",
finding="layout note",
why="place close",
status="ERROR",
rule_id="PE-THM-001",
source="pcb_power_thermal",
evidence_status="SUFFICIENT",
facts="no pour",
requirement="datasheet layout page",
)
complete_finding(f)
assert f.provenance == "RECOMMENDED"
assert f.status != "ERROR"
assert f.finding_class != "RULE"
def test_llm_review_is_never_rule():
result = _parse_review(
{
"findings": [{
"finding": "strap",
"why": "datasheet",
"status": "ERROR",
"source_page": 2,
"source_quote": "PSEL must be high for 3.3 V.",
"recommendation": "Tie PSEL high.",
}],
"checked_areas": [],
},
"U1",
"PART",
)
f = result.findings[0]
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.facts
assert f.requirement
assert f.status == "WARNING"
def test_review_and_risk_cannot_stay_error():
review = Finding(
designator="U1",
finding="PowerPAD vias",
why="layout note",
status="ERROR",
source="pcb_review",
facts="0 vias under U1",
requirement="datasheet PowerPAD",
source_quote="Connect the thermal pad with vias to GND.",
evidence_status="SUFFICIENT",
)
complete_finding(review)
assert review.finding_class == "REVIEW"
assert review.status == "WARNING"
risk = Finding(
designator="C1",
finding="high utilization",
why="Vop near Vr",
status="ERROR",
rule_id="PE-DRT-002",
source="pcb_derating",
evidence_status="SUFFICIENT",
facts="3.0 / 3.3 V",
requirement="utilization band",
)
complete_finding(risk)
assert risk.finding_class == "RISK"
assert risk.status == "WARNING"
def test_mandatory_rule_stays_error():
f = Finding(
designator="U1",
finding="NC tied",
why="NC must float",
status="ERROR",
rule_id="PE-NC-001",
source="nc_pin_check",
facts="NC on GND",
requirement="NC pins must not be connected.",
evidence_status="SUFFICIENT",
)
complete_finding(f)
assert f.finding_class == "RULE"
assert f.provenance == "MANDATORY"
assert f.status == "ERROR"
def test_insufficient_evidence_does_not_stay_error():
f = Finding(
designator="U1",
finding="narrow trace",
why="",
status="ERROR",
source="pcb_power_thermal",
rule_id="PE-PWR-001",
evidence_status="INSUFFICIENT",
)
complete_finding(f)
assert f.status != "ERROR"
assert "Insufficient evidence" in f.inference
assert f.finding_class != "RULE" or f.evidence_status == "INSUFFICIENT"
def test_decision_suppresses_rescan_nag():
f = Finding(
designator="U1",
finding="PSEL low",
why="must be high",
status="ERROR",
rule_id="PE-MUX-001",
source="pin_mux_check",
net="PSEL",
aspect="config",
facts="PSEL net is GND",
requirement="PSEL high for 3V3",
)
complete_finding(f)
assert f.status == "ERROR"
d = DesignerDecision(
decision_id="DEC-1",
fingerprint=finding_fingerprint(f),
rule_id="PE-MUX-001",
designator="U1",
net="PSEL",
aspect="config",
intent="wontfix",
reason="PSEL low intentional",
)
apply_decisions([f], [d])
assert f.suppressed is True
assert f.status == "INFO"
assert f.finding_class == "INFO"
assert "PSEL low intentional" in f.inference
def test_schematic_assign_ids_fills_engine_fields():
f = Finding(
designator="U3",
finding="NC tied",
why="NC must float",
status="ERROR",
rule_id="PE-NC-001",
source="nc_pin_check",
)
assign_finding_ids([f])
assert f.finding_id == "U3-001"
assert f.facts == "NC tied"
assert f.requirement
assert f.provenance == "MANDATORY"
assert f.finding_class == "RULE"
assert f.confidence is not None
def test_same_object_pcb_and_schema():
assert lookup_rule("PE-NC-001").domain == "schema"
assert lookup_rule("PE-LAY-001").domain == "pcb"
sch = Finding(designator="U1", finding="x", status="INFO", rule_id="PE-NC-001", source="nc_pin_check")
pcb = Finding(designator="U1", finding="y", status="INFO", rule_id="PE-LAY-001", source="pcb_net_match")
complete_finding(sch)
complete_finding(pcb)
for obj in (sch, pcb):
assert obj.facts
assert obj.finding_class
assert obj.provenance
assert obj.evidence_status
assert (obj.action or "").strip()
def test_empty_recommendation_gets_action():
f = Finding(designator="U1", finding="note", status="INFO", source="review")
complete_finding(f)
assert f.action.strip()
assert f.recommendation.strip() == f.action.strip()
def test_pcb_review_ai_finding_action_without_recommendation():
f = Finding(
designator="U1",
finding="PowerPAD vias",
why="thermal pad",
status="INFO",
source="pcb_review",
recommendation="",
action="",
)
complete_finding(f)
assert f.action.strip()
assert f.finding_class == "REVIEW"
def test_recommended_rc_cap_is_review_not_rule():
f = Finding(
designator="C1",
finding="10n vs 100n",
why="datasheet typical 100n",
status="WARNING",
rule_id="PE-DEC-002",
source="passive_rail_check",
)
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.provenance == "RECOMMENDED"
assert f.status != "ERROR"
+107
View File
@@ -0,0 +1,107 @@
"""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.periscopex.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="PE-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 == "PE-DEC-001"
assert again.cad_sheet == "power.kicad_sch"
assert again.cad_uuid == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
assert again.variant == "DNP"
def test_engine_fields_round_trip():
f = Finding(
designator="U1",
finding="x",
status="WARNING",
facts="net GND",
requirement="PSEL high",
inference="intentional?",
provenance="RECOMMENDED",
finding_class="RISK",
confidence=0.4,
evidence_status="SUFFICIENT",
calculation="n/a",
assumptions=["firmware unknown"],
action="leave PSEL low",
)
again = Finding.model_validate(json.loads(f.model_dump_json()))
assert again.facts == "net GND"
assert again.provenance == "RECOMMENDED"
assert again.finding_class == "RISK"
assert again.assumptions == ["firmware unknown"]
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")
+288
View File
@@ -0,0 +1,288 @@
"""Layout F1 functional_groups on simple_project — topology only, no mm."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
import json
from pathlib import Path
from backend.periscopex.functional_groups import build_functional_groups
from backend.periscopex.models import DesignGraph
SIMPLE = SIMPLE_PROJECT
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text(encoding="utf-8"),
)
def test_simple_project_has_mcu_ldo_bridge_groups():
report = build_functional_groups(_graph())
assert report.objective == "routing"
refs = {g.ref for g in report.groups}
assert {"U1", "U2", "U3"} <= refs
# No millimetre fields on the report model dump
raw = json.loads(report.model_dump_json())
blob = json.dumps(raw)
assert "max_distance_mm" not in blob or all(
r.get("max_distance_mm") is None
for g in raw["groups"]
for r in g.get("layout_rules") or []
)
def test_u3_owns_crystal_load_caps():
report = build_functional_groups(_graph())
u3 = next(g for g in report.groups if g.ref == "U3")
sat = {s.ref: s.role_hint for s in u3.satellites}
assert "X1" in sat
assert sat["X1"] == "crystal"
assert sat.get("C9") == "load_cap" or sat.get("C10") == "load_cap"
assert "C9" in sat and "C10" in sat
assert sat["C9"] == "load_cap"
assert sat["C10"] == "load_cap"
def test_u1_has_decoupling_or_bulk_on_rails():
report = build_functional_groups(_graph())
u1 = next(g for g in report.groups if g.ref == "U1")
roles = {s.role_hint for s in u1.satellites}
assert roles & {"decoupling", "bulk"}
def test_domains_cover_all_ics():
report = build_functional_groups(_graph())
covered = {r for d in report.domains for r in d.ic_refs}
assert covered == {"U1", "U2", "U3"}
assert all(d.assemble_order for d in report.domains)
def test_simple_project_splits_5v_and_3v3_domains():
"""LDO bridges +5V/+3V3 electrically but domains follow primary rails."""
report = build_functional_groups(_graph())
by_rail = {d.power_nets[0]: set(d.ic_refs) for d in report.domains if d.power_nets}
assert by_rail.get("+5V") == {"U2"}
assert by_rail.get("+3V3") == {"U1", "U3"}
assert len(report.domains) == 2
def test_ldo_power_satellites_stay_on_primary_rail():
"""LDO input-rail caps must not appear as primary-rail satellites."""
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentType,
DesignGraph,
InductorSpecs,
Net,
NetType,
PinConnection,
)
components = {
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.power.ldo",
pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND", "4": "EN"},
),
"C_in": Component(
reference="C_in", value="10u", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "VSYS", "2": "GND"},
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10uF"),
),
"L1": Component(
reference="L1", value="2.2u", footprint="",
component_type=ComponentType.INDUCTOR,
pins={"1": "VSYS", "2": "VSYS"},
specs=InductorSpecs(value_henries=2.2e-6, value_formatted="2.2uH"),
),
"C_out": Component(
reference="C_out", value="100n", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "3V3_DIGITAL", "2": "GND"},
specs=CapacitorSpecs(value_farads=100e-9, value_formatted="100nF"),
),
"C_en": Component(
reference="C_en", value="1u", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "EN", "2": "GND"},
specs=CapacitorSpecs(value_farads=1e-6, value_formatted="1uF"),
),
"C_boot": Component(
reference="C_boot", value="47n", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "BTST", "2": "SW"},
specs=CapacitorSpecs(value_farads=47e-9, value_formatted="47nF"),
),
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "3V3_DIGITAL", "2": "GND"},
),
"C_noise": Component(
reference="C_noise", value="100n", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "orphan", "2": "somewhere"},
),
}
# Extend U1 pins for bootstrap
components["U1"].pins["5"] = "BTST"
components["U1"].pins["6"] = "SW"
nets = {
"VSYS": Net(
name="VSYS", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="C_in", pin_number="1"),
PinConnection(component_ref="L1", pin_number="1"),
],
),
"3V3_DIGITAL": Net(
name="3V3_DIGITAL", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="C_out", pin_number="1"),
PinConnection(component_ref="J1", pin_number="1"),
],
),
"EN": Net(
name="EN", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="4"),
PinConnection(component_ref="C_en", pin_number="1"),
],
),
"BTST": Net(
name="BTST", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="5"),
PinConnection(component_ref="C_boot", pin_number="1"),
],
),
"SW": Net(
name="SW", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="6"),
PinConnection(component_ref="C_boot", pin_number="2"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="U1", pin_number="3"),
PinConnection(component_ref="C_in", pin_number="2"),
PinConnection(component_ref="C_out", pin_number="2"),
PinConnection(component_ref="C_en", pin_number="2"),
PinConnection(component_ref="J1", pin_number="2"),
],
),
"orphan": Net(
name="orphan", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="C_noise", pin_number="1")],
),
"somewhere": Net(
name="somewhere", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="C_noise", pin_number="2")],
),
}
report = build_functional_groups(DesignGraph(components=components, nets=nets))
u1 = next(g for g in report.groups if g.ref == "U1")
sat = {s.ref: s.role_hint for s in u1.satellites}
assert sat.get("C_out") == "decoupling"
assert sat.get("C_en") == "bulk"
assert sat.get("C_boot") == "bridge"
assert "C_in" not in sat
assert "L1" not in sat
assert "J1" not in sat
assert "C_noise" not in sat
assert "other" not in sat.values()
def test_multi_rail_board_does_not_collapse_to_one_domain():
"""Charger→LDO→MCU must not become a single domain via shared POWER nets."""
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
components = {
"U1": Component(
reference="U1", value="BQ25896", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.power.battery_charger",
pins={"1": "VBUS", "2": "VSYS", "3": "GND"},
),
"U2": Component(
reference="U2", value="AP2112", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.power.ldo",
pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND"},
),
"U3": Component(
reference="U3", value="ESP32", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.rf.wifi_module",
pins={"1": "3V3_DIGITAL", "2": "GND"},
),
"U4": Component(
reference="U4", value="SRV05", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.protection.esd",
pins={"1": "VBUS", "2": "GND"},
),
}
nets = {
"VBUS": Net(
name="VBUS", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="U4", pin_number="1"),
],
),
"VSYS": Net(
name="VSYS", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="U2", pin_number="1"),
],
),
"3V3_DIGITAL": Net(
name="3V3_DIGITAL", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U2", pin_number="2"),
PinConnection(component_ref="U3", pin_number="1"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="U1", pin_number="3"),
PinConnection(component_ref="U2", pin_number="3"),
PinConnection(component_ref="U3", pin_number="2"),
PinConnection(component_ref="U4", pin_number="2"),
],
),
}
report = build_functional_groups(DesignGraph(components=components, nets=nets))
by_rail = {d.power_nets[0]: set(d.ic_refs) for d in report.domains if d.power_nets}
assert len(report.domains) >= 3
assert by_rail.get("VBUS") == {"U4"}
assert by_rail.get("VSYS") == {"U1"}
assert by_rail.get("3V3_DIGITAL") == {"U2", "U3"}
def test_build_placement_plan_alias():
from backend.periscopex.functional_groups import build_placement_plan
report = build_placement_plan(_graph())
assert report.objective == "routing"
assert {g.ref for g in report.groups} >= {"U1", "U2", "U3"}
+47
View File
@@ -0,0 +1,47 @@
"""HubAudio KiCad BOM: PNM column, grouped refs, no invented U13…U39 MPNs."""
from tests.paths import FIXTURES
from backend.periscopex.parsers import ic_mpn_skip_reason, parse_bom
FIXTURE = FIXTURES / "hubaudio_kicad_bom.csv"
SKIPPED = ("U13", "U15", "U25", "U28", "U34", "U35", "U36", "U39")
def test_hubaudio_header_is_pnm_not_mpn():
header = FIXTURE.read_text(encoding="utf-8-sig").splitlines()[0]
assert "PNM" in header
assert "Manufacturer Part Number" not in header
def test_hubaudio_pnm_and_value_fallback():
bom = parse_bom(FIXTURE, mpn_col="Manufacturer Part Number")
assert bom["U1"]["mpn"] == "TPS22965DSGR"
assert bom["U10"]["mpn"] == "W25Q128JVS"
assert bom["U11"]["mpn"] == "ESP32-S31-WROOM-3"
# PNM blank, Value is the orderable MPN; grouped Reference list.
assert bom["U2"]["mpn"] == "PCA9534ARGTR"
assert bom["U3"]["mpn"] == "PCA9534ARGTR"
assert bom["U2"]["value"] == "PCA9534ARGTR"
assert (bom["U4"]["mpn"] == "INA228AQDGSRQ1") and bom["U8"]["mpn"] == "INA228AQDGSRQ1"
assert bom["L4"]["mpn"] == "BLM21PG121SN1D"
assert bom["L4"]["value"] == "120 ohm"
def test_hubaudio_skipped_refs_absent_no_invented_mpn():
bom = parse_bom(FIXTURE, mpn_col="Manufacturer Part Number")
for ref in SKIPPED:
assert ref not in bom
reason = ic_mpn_skip_reason(
ref, mpn=None, value="", footprint="MIKILAB_TPD2E007DCKR:SOT65P210X110-3N",
in_bom=False,
)
assert reason and reason.startswith("INSUFFICIENT_EVIDENCE")
assert "not in BOM" in reason
def test_ic_skip_empty_pnm_quotes_value():
reason = ic_mpn_skip_reason(
"U99", mpn=None, value="", footprint="QFN", in_bom=True, bom_value="",
)
assert reason and "PNM/MPN empty" in reason
@@ -0,0 +1,624 @@
"""Interface class certifiers — USB-C, Ethernet 10/100 vs GbE, PoE.
First slice: class + connection integrity. Not generic SI, not via ampacity,
not thermal FEM. Missing evidence is INSUFFICIENT or N/A, never invented
geometry / Z / I / PoE / SuperSpeed.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import merge_schema_pcb_reports, run_pcb_checks
from tests.paths import SIMPLE_PROJECT
def _comp(
ref: str,
*,
ctype: ComponentType,
value: str = "",
footprint: str = "",
mpn: str = "",
pins: dict[str, str] | None = None,
subtype: str | None = None,
specs=None,
) -> Component:
return Component(
reference=ref,
value=value,
footprint=footprint,
component_type=ctype,
component_subtype=subtype,
mpn=mpn or None,
pins=pins or {},
specs=specs,
)
def _net(name: str, ntype: NetType, *refs_pins: tuple[str, str]) -> Net:
return Net(
name=name,
net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in refs_pins],
)
def _by_rule(findings, rule_id: str, designator: str | None = None):
out = [
f for f in findings
if f.rule_id == rule_id and (designator is None or f.designator == designator)
]
return out
def _rd(ref: str, cc_net: str) -> Component:
return _comp(
ref,
ctype=ComponentType.RESISTOR,
value="5k1",
pins={"1": cc_net, "2": "GND"},
specs=ResistorSpecs(
value_ohms=5100.0, value_formatted="5.1k",
),
)
def _usbc_device_graph(*, ss: bool = False, usb2_value: bool = True) -> DesignGraph:
"""USB-C receptacle with Rd 5.1 kΩ on CC1/CC2, VBUS, GND."""
value = "USB_C_Receptacle_USB2.0_16P" if usb2_value else "USB_C_Receptacle_USB3.1"
pins = {
"A5": "USB_CC1",
"B5": "USB_CC2",
"A4": "VBUS",
"A9": "VBUS",
"A1": "GND",
"A12": "GND",
"A6": "USB_D+",
"A7": "USB_D-",
}
nets = {
"USB_CC1": _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("R10", "1")),
"USB_CC2": _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("R11", "1")),
"VBUS": _net("VBUS", NetType.POWER, ("J2", "A4"), ("J2", "A9")),
"GND": _net("GND", NetType.GROUND, ("J2", "A1"), ("J2", "A12"), ("R10", "2"), ("R11", "2")),
"USB_D+": _net("USB_D+", NetType.SIGNAL, ("J2", "A6")),
"USB_D-": _net("USB_D-", NetType.SIGNAL, ("J2", "A7")),
}
comps = {
"J2": _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
mpn="TYPE-C-31-M-12",
subtype="connector.usb",
pins=pins,
),
"R10": _rd("R10", "USB_CC1"),
"R11": _rd("R11", "USB_CC2"),
}
if ss:
pins["A2"] = "USB_SSTX_P"
pins["A3"] = "USB_SSTX_N"
pins["B11"] = "USB_SSRX_P"
pins["B10"] = "USB_SSRX_N"
nets["USB_SSTX_P"] = _net("USB_SSTX_P", NetType.SIGNAL, ("J2", "A2"))
nets["USB_SSTX_N"] = _net("USB_SSTX_N", NetType.SIGNAL, ("J2", "A3"))
nets["USB_SSRX_P"] = _net("USB_SSRX_P", NetType.SIGNAL, ("J2", "B11"))
nets["USB_SSRX_N"] = _net("USB_SSRX_N", NetType.SIGNAL, ("J2", "B10"))
comps["J2"] = _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value="USB_C_Receptacle_USB3.1",
footprint="Connector_USB:USB_C_Receptacle_24P",
mpn="USB3-C-24",
subtype="connector.usb",
pins=pins,
)
return DesignGraph(components=comps, nets=nets)
def test_no_connector_emits_nothing():
"""No USB-C / no RJ45 / no PoE evidence → do not emit that certifier."""
g = DesignGraph(
components={
"U1": _comp("U1", ctype=ComponentType.IC, value="MCU", pins={"1": "GND"}),
},
nets={"GND": _net("GND", NetType.GROUND, ("U1", "1"))},
)
findings = check_interface_classes(g)
assert findings == []
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_usbc_rd_vbus_gnd_certified():
findings = check_interface_classes(_usbc_device_graph())
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-USBC-001", "J2")[0]
assert cls.status == "INFO"
assert "USB2" in cls.finding
assert _by_rule(findings, "PE-USBC-002", "J2")[0].status == "INFO"
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
assert "5100" in rd.facts or "5.1" in rd.facts
assert _by_rule(findings, "PE-USBC-004", "J2")[0].status == "INFO"
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status != "ERROR"
assert ss.finding_class == "INFO"
assert "N/A" in ss.finding or "USB2" in ss.finding
assert all(f.status != "ERROR" for f in findings if f.designator == "J2")
assert not any((f.rule_id or "").startswith(("PE-ETH", "PE-POE", "PE-DDR")) for f in findings)
def test_usbc_missing_cc2_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = dict(j2.pins)
pins.pop("B5")
g.components["J2"] = j2.model_copy(update={"pins": pins})
del g.nets["USB_CC2"]
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cc = _by_rule(findings, "PE-USBC-002", "J2")[0]
assert cc.status == "ERROR"
assert cc.finding_class == "RULE"
assert cc.evidence_status == "SUFFICIENT"
def test_usbc_wrong_rd_is_error():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="10k",
pins={"1": "USB_CC1", "2": "GND"},
specs=ResistorSpecs(value_ohms=10000.0, value_formatted="10k"),
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "ERROR"
assert rd.finding_class == "RULE"
def test_usbc_cc_to_controller_without_r_is_insufficient_not_error():
g = _usbc_device_graph()
del g.components["R10"]
del g.components["R11"]
g.components["U5"] = _comp(
"U5",
ctype=ComponentType.IC,
value="CC controller",
mpn="CC-IC",
pins={"1": "USB_CC1", "2": "USB_CC2"},
)
g.nets["USB_CC1"] = _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("U5", "1"))
g.nets["USB_CC2"] = _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("U5", "2"))
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
assert rd.finding_class == "INFO"
def test_usbc_unknown_r_value_is_insufficient():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="",
pins={"1": "USB_CC1", "2": "GND"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
def test_usb3_class_without_ss_pairs_is_error():
g = _usbc_device_graph(usb2_value=False)
j2 = g.components["J2"]
g.components["J2"] = j2.model_copy(update={
"value": "USB_C_Receptacle_USB3.1",
"footprint": "Connector_USB:USB_C_Receptacle_24P",
"mpn": "USB3-C",
})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "ERROR"
assert ss.finding_class == "RULE"
assert "SuperSpeed" in ss.finding or "USB3" in ss.finding
def test_usb3_with_ss_pairs_is_certified():
findings = check_interface_classes(_usbc_device_graph(ss=True, usb2_value=False))
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "INFO"
assert ss.evidence_status == "SUFFICIENT"
assert ss.status != "ERROR"
def test_missing_vbus_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = {k: v for k, v in j2.pins.items() if v != "VBUS"}
g.components["J2"] = j2.model_copy(update={"pins": pins})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
pwr = _by_rule(findings, "PE-USBC-004", "J2")[0]
assert pwr.status == "ERROR"
assert pwr.finding_class == "RULE"
def _rj45(
*,
value: str,
mpn: str,
pins: dict[str, str],
extra_comps: dict[str, Component] | None = None,
extra_nets: dict[str, Net] | None = None,
) -> DesignGraph:
comps = {
"J1": _comp(
"J1",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_RJ:RJ45",
mpn=mpn,
pins=pins,
),
}
nets = {}
for pin, net in pins.items():
nets.setdefault(net, Net(name=net, net_type=NetType.SIGNAL, pins=[]))
nets[net].pins.append(PinConnection(component_ref="J1", pin_number=pin))
if net.upper() in {"GND", "AGND"}:
nets[net].net_type = NetType.GROUND
if extra_comps:
comps.update(extra_comps)
if extra_nets:
for n, net in extra_nets.items():
if n in nets:
nets[n].pins.extend(net.pins)
else:
nets[n] = net
return DesignGraph(components=comps, nets=nets)
def test_ethernet_10_100_magjack_no_gbe_magnetics_error():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status == "INFO"
assert "10/100" in cls.finding
assert "GbE" not in cls.finding or "not GbE" in cls.finding.lower() or "10/100" in cls.finding
pairs = _by_rule(findings, "PE-ETH-002", "J1")[0]
assert pairs.status == "INFO"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
assert "N/A" in mag.finding or "10/100" in mag.finding
def test_gbe_without_magnetics_is_error():
g = _rj45(
value="RJ45 1000BASE-T",
mpn="RJ45-GBE-BARE",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert "GbE" in cls.finding or "1000" in cls.finding
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "ERROR"
assert mag.finding_class == "RULE"
assert mag.evidence_status == "SUFFICIENT"
def test_gbe_with_magnetics_is_certified():
g = _rj45(
value="RJ45 1000BASE-T MagJack",
mpn="PULSE-GBE-MAG",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
extra_comps={
"T1": _comp(
"T1",
ctype=ComponentType.TRANSFORMER,
value="LAN magnetics",
mpn="HX1198FNL",
subtype="transformer.signal",
pins={"1": "TRD0_P", "2": "TRD0_N"},
),
},
extra_nets={
"TRD0_P": _net("TRD0_P", NetType.SIGNAL, ("T1", "1")),
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "INFO"
assert mag.evidence_status == "SUFFICIENT"
assert mag.status != "ERROR"
def test_rj45_without_speed_is_insufficient_not_invented_gbe():
g = _rj45(
value="RJ45",
mpn="8P8C",
pins={"1": "NET1", "2": "NET2", "3": "NET3", "6": "NET6"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
def test_bare_rj45_does_not_invent_poe():
g = _rj45(
value="RJ45",
mpn="8P8C-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-ETH-001", "J1")
assert not any((f.rule_id or "").startswith("PE-POE") for f in findings)
assert not any((f.rule_id or "").startswith("PE-USBC") for f in findings)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
assert all(f.status != "ERROR" for f in findings if (f.rule_id or "").startswith("PE-ETH"))
def test_poe_with_evidence_requires_magnetics_isolation():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ev = _by_rule(findings, "PE-POE-001", "J1")[0]
assert ev.status == "INFO"
assert ev.evidence_status == "SUFFICIENT"
assert "PoE" in ev.finding
cls = _by_rule(findings, "PE-POE-002", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "INFO"
assert iso.evidence_status == "SUFFICIENT"
def test_poe_evidence_on_bare_jack_is_isolation_error():
g = _rj45(
value="RJ45 PoE 802.3af",
mpn="RJ45-POE-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "ERROR"
assert iso.finding_class == "RULE"
def test_simple_project_usbc_not_false_error():
graph = DesignGraph.model_validate_json(
(SIMPLE_PROJECT / "design_graph.json").read_text()
)
findings = check_interface_classes(graph)
for f in findings:
complete_finding(f)
usbc = [f for f in findings if (f.rule_id or "").startswith("PE-USBC") and f.designator == "J1"]
assert usbc
assert all(f.status != "ERROR" for f in usbc)
rd = _by_rule(findings, "PE-USBC-003", "J1")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
ss = _by_rule(findings, "PE-USBC-005", "J1")[0]
assert ss.status != "ERROR"
assert ss.evidence_status == "INSUFFICIENT" or "N/A" in ss.finding
assert not any((f.rule_id or "").startswith("PE-ETH") for f in findings)
assert not any((f.rule_id or "").startswith("PE-POE") for f in findings)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_hubaudio_like_usbc_usb2_and_poe_magjack():
"""HubAudio J2 is USB2 Type-C 16P; J1 is PoE 10/100 MagJack — no false SS/GbE/PoE class."""
usbc = _usbc_device_graph()
eth = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
comps = dict(usbc.components)
comps.update(eth.components)
nets = dict(usbc.nets)
for name, net in eth.nets.items():
if name in nets:
nets[name] = nets[name].model_copy(
update={"pins": list(nets[name].pins) + list(net.pins)},
)
else:
nets[name] = net
g = DesignGraph(components=comps, nets=nets)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-USBC-005", "J2")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-003", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-002", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-003", "J1")[0].status != "ERROR"
assert all(
f.status != "ERROR"
for f in findings
if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))
)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr():
"""HubAudio actual parts: J2 USB-C USB2 16P, J1 PoE 10/100 MagJack, no DDR."""
from tests.paths import REPO_ROOT
bom = (REPO_ROOT / "tests" / "fixtures" / "hubaudio_kicad_bom.csv").read_text()
assert "USB_C_Receptacle_USB2.0_16P" in bom
assert "RJ45" in bom and "PoE" in bom
assert "DDR3" not in bom and "DDR2" not in bom and "DDR4" not in bom
assert "USB3" not in bom
def test_rule_catalog_shared_not_si():
for rid in (
"PE-USBC-001", "PE-USBC-002", "PE-USBC-003", "PE-USBC-004", "PE-USBC-005",
"PE-ETH-001", "PE-ETH-002", "PE-ETH-003", "PE-ETH-004",
"PE-POE-001", "PE-POE-002", "PE-POE-003",
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == "shared"
def test_run_pcb_checks_skips_absent_interfaces():
findings = run_pcb_checks(DesignGraph(), {}, None)
ids = {f.rule_id for f in findings}
assert "PE-USBC-001" not in ids
assert "PE-ETH-001" not in ids
assert "PE-POE-001" not in ids
if_f = [f for f in findings if (f.rule_id or "").startswith(
("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR", "PE-CPU", "PE-FPGA")
)]
assert if_f == []
def test_merge_drops_duplicate_pcb_interface_findings():
schema = {
"findings": [{
"finding_id": "J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
pcb = {
"findings": [{
"finding_id": "PCB-J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
merged = merge_schema_pcb_reports(schema, pcb)
usbc = [
f for f in merged["findings"]
if f.get("rule_id") == "PE-USBC-001" and f.get("designator") == "J2"
]
assert len(usbc) == 1
assert usbc[0]["finding_id"] == "J2-001"
def test_does_not_use_layout_geometry():
"""Certifier takes the netlist graph only — no invented mm / Z / I."""
import inspect
from backend.periscopex import interface_class_check as mod
sig = inspect.signature(mod.check_interface_classes)
assert list(sig.parameters) == ["graph"]
src = inspect.getsource(mod)
assert "LayoutVia" not in src
assert "LayoutPad" not in src
assert "LayoutSegment" not in src
assert "LayoutZone" not in src
def test_usbc_vbus_does_not_invent_usb_500ma():
"""No typical USB current and no PE-PWR check without datasheet I."""
findings = check_interface_classes(_usbc_device_graph())
blob = " ".join(
" ".join(filter(None, (f.finding, f.why, f.facts, f.requirement, f.inference)))
for f in findings
).lower()
assert "500 ma" not in blob
assert "500ma" not in blob
assert "0.5 a" not in blob
assert not any(f.rule_id in {"PE-PWR-001", "PE-VIA-001"} for f in findings)
@@ -0,0 +1,83 @@
"""Internal features (block-diagram extraction) — open-drain pull-up.
Favor: pin listed in pullup_pins with no resistor to a rail → PE-INT-001.
Against: empty internal_features is silent; listed pin with a pull-up is
silent; a pin not in pullup_pins is not guessed as open-drain.
"""
from __future__ import annotations
from backend.periscopex.internal_features_check import check_internal_features
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InternalFeatures,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
def _graph(with_pull: bool):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="UTEST",
pins={"1": "SDA", "2": "GND"},
)
comps = {"U1": u}
nets = {
"SDA": (NetType.SIGNAL, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
"3V3": (NetType.POWER, []),
}
if with_pull:
r = Component(
reference="R1", value="4k7", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "SDA", "2": "3V3"},
specs=ResistorSpecs(value_ohms=4700, value_formatted="4k7"),
)
comps["R1"] = r
nets["SDA"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
nets["3V3"] = (NetType.POWER, [("R1", "2")])
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=a, pin_number=str(b)) for a, b in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=comps, nets=net_objs)
def _cons(features: InternalFeatures | None):
return {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=1, name="SDA"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
internal_features=features,
)
}
def test_listed_open_drain_without_pull_is_warning():
feats = InternalFeatures(pullup_pins=["SDA"])
findings = check_internal_features(_graph(False), _cons(feats))
assert len(findings) == 1
assert findings[0].rule_id == "PE-INT-001"
assert findings[0].status == "WARNING"
def test_listed_pin_with_pullup_is_silent():
feats = InternalFeatures(pullup_pins=["SDA"])
assert check_internal_features(_graph(True), _cons(feats)) == []
def test_empty_features_does_not_guess_open_drain():
assert check_internal_features(_graph(False), _cons(None)) == []
assert check_internal_features(_graph(False), _cons(InternalFeatures())) == []
+528
View File
@@ -0,0 +1,528 @@
from pathlib import Path
from backend.periscopex.parsers import detect_netlist_format, parse_netlist_any, validate_netlist
XML = """<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
<components>
<comp ref="U1">
<value>MSPM0G3507</value>
<footprint>Package_QFP:LQFP-48_7x7mm_P0.5mm</footprint>
<fields>
<field name="MPN">MSPM0G3507SPTR</field>
</fields>
</comp>
<comp ref="C1">
<value>100n</value>
<footprint>Capacitor_SMD:C_0603</footprint>
</comp>
<comp ref="R1">
<value>10k</value>
<footprint>Resistor_SMD:R_0603</footprint>
</comp>
</components>
<nets>
<net code="1" name="GND">
<node ref="U1" pin="8"/>
<node ref="C1" pin="2"/>
</net>
<net code="2" name="3V3">
<node ref="U1" pin="1"/>
<node ref="C1" pin="1"/>
<node ref="R1" pin="2"/>
</net>
<net code="3" name="I2C_SDA">
<node ref="U1" pin="12"/>
<node ref="R1" pin="1"/>
</net>
</nets>
</export>
"""
SEXP = """(export (version "E")
(components
(comp (ref "U1")
(value "MSPM0G3507")
(footprint "Package_QFP:LQFP-48")
(fields
(field (name "MPN") "MSPM0G3507SPTR")
(field (name "LCSC") "C12345")
)
)
(comp (ref "C1")
(value "100n")
(footprint "C_0603")
)
)
(nets
(net (code "1") (name "GND")
(node (ref "U1") (pin "8"))
(node (ref "C1") (pin "2"))
)
(net (code "2") (name "3V3")
(node (ref "U1") (pin "1"))
(node (ref "C1") (pin "1"))
)
)
)
"""
def test_detect_kicad_xml():
assert detect_netlist_format(XML) == "kicad_xml"
assert detect_netlist_format(SEXP) == "kicad_sexp"
assert detect_netlist_format("(kicad_sch (version 20231120)") == "kicad_sch"
def test_parse_kicad_xml(tmp_path: Path):
p = tmp_path / "net.xml"
p.write_text(XML)
parts, nets, fmt = parse_netlist_any(p)
assert fmt == "kicad_xml"
assert parts["U1"].startswith("Package_QFP")
assert ("U1", "8") in nets["GND"]
assert ("C1", "1") in nets["3V3"]
assert ("R1", "1") in nets["I2C_SDA"]
assert validate_netlist(parts, nets) == []
def test_parse_kicad_sexp_and_mpn_fields(tmp_path: Path):
p = tmp_path / "net.kicad_net"
p.write_text(SEXP)
parts, nets, fmt = parse_netlist_any(p)
assert fmt == "kicad_sexp"
assert ("U1", "1") in nets["3V3"]
from backend.periscopex.parsers_kicad import kicad_part_fields
fields = kicad_part_fields(p)
assert fields["U1"]["mpn"] == "MSPM0G3507SPTR"
assert fields["U1"]["lcsc"] == "C12345"
def test_kicad_mpn_fills_empty_bom(tmp_path: Path):
from backend.periscopex.graph import build_graph
net = tmp_path / "net.xml"
net.write_text(XML)
bom = tmp_path / "bom.csv"
bom.write_text(
"Reference,Value,Footprint,Manufacturer Part Number\n"
"U1,MSPM0,,\nC1,100n,C_0603,\nR1,10k,R_0603,\n"
)
g = build_graph(
net, bom, tmp_path / "empty_ex", tmp_path / "empty_pat", tmp_path / "empty_mod",
)
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))))
)
)
(symbol "power:GND"
(power)
(pin power_in (at 0 0 270) (length 0)
(name "GND" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
)
)
"""
def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:R")
(at {x} {y} 0)
(unit 1)
(uuid "{uid}")
(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 _power_gnd(x: float, y: float, suffix: str = "1") -> str:
uid = f"bbbbbbbb-bbbb-bbbb-bbbb-{suffix.zfill(12)}"
return f"""
(symbol
(lib_id "power:GND")
(at {x} {y} 0)
(unit 1)
(uuid "{uid}")
(property "Reference" "#{suffix}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "GND" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "pgnd{suffix}"))
)
"""
def _sch(*body: str) -> str:
return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n"
def test_power_symbols_same_name_merge_without_wires(tmp_path: Path):
"""KiCad power flags are global: two GND symbols share one net even if islands."""
from backend.periscopex.parsers import parse_netlist_any
p = tmp_path / "power.kicad_sch"
# R1 and R2 far apart, each with GND on pin 1, no wires between them.
p.write_text(_sch(
_resistor("R1", "10k", x=0, y=0),
_power_gnd(0, 3.81, "1"),
_resistor("R2", "10k", x=100, y=0),
_power_gnd(100, 3.81, "2"),
))
_parts, nets, fmt = parse_netlist_any(p)
assert fmt == "kicad_sch"
assert "GND" in nets
assert ("R1", "1") in nets["GND"]
assert ("R2", "1") in nets["GND"]
gnd_like = [n for n in nets if n.rstrip("_") == "GND"]
assert gnd_like == ["GND"], gnd_like
def test_local_labels_same_name_merge_on_same_sheet(tmp_path: Path):
from backend.periscopex.parsers import parse_netlist_any
p = tmp_path / "local.kicad_sch"
p.write_text(_sch(
_resistor("R1", "10k", x=0, y=0),
"""
(label "NETA" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"))
""",
_resistor("R2", "10k", x=100, y=0),
"""
(label "NETA" (at 100 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2"))
""",
))
_parts, nets, _fmt = parse_netlist_any(p)
assert ("R1", "1") in nets["NETA"]
assert ("R2", "1") in nets["NETA"]
def test_pin_on_mid_wire_segment_connects(tmp_path: Path):
from backend.periscopex.parsers import parse_netlist_any
p = tmp_path / "midwire.kicad_sch"
# Horizontal wire from (-10,3.81) to (10,3.81); R1 pin1 at (0,3.81) sits mid-segment.
p.write_text(_sch(
_resistor("R1", "10k", x=0, y=0),
_resistor("R2", "10k", x=10, y=0),
"""
(wire (pts (xy -10 3.81) (xy 10 3.81)))
(global_label "SIG" (at -10 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
""",
))
_parts, nets, _fmt = parse_netlist_any(p)
assert ("R1", "1") in nets["SIG"]
assert ("R2", "1") in nets["SIG"]
def test_parse_single_sheet_kicad_sch(tmp_path: Path):
from backend.periscopex.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.periscopex.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.periscopex.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.periscopex.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.periscopex.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.periscopex.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.periscopex.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)
def _ic_symbol(ref: str, value: str, *, pnm: str = "", mpn: str = "", x: float = 0, y: float = 0) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
extra = ""
if pnm:
extra += (
f' (property "PNM" "{pnm}" (at 0 0 0) '
f'(effects (font (size 1.27 1.27))))\n'
)
if mpn:
extra += (
f' (property "MPN" "{mpn}" (at 0 0 0) '
f'(effects (font (size 1.27 1.27))))\n'
)
return f"""
(symbol
(lib_id "Device:R")
(at {x} {y} 0)
(unit 1)
(uuid "{uid}")
(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))))
{extra} (pin "1" (uuid "p1{ref}"))
(pin "2" (uuid "p2{ref}"))
)
"""
def _hier_root_with_child(tmp_path: Path, child_body: str) -> Path:
child = tmp_path / "codec.kicad_sch"
child.write_text(_sch(
child_body,
"""
(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" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
return root
def test_hierarchical_pnm_fills_graph_when_bom_omits_ref(tmp_path: Path):
from backend.periscopex.graph import build_graph
root = _hier_root_with_child(
tmp_path,
_ic_symbol("U13", "TPD2E007DCKR", pnm="TPD2E007DCKR"),
)
bom = tmp_path / "bom.csv"
bom.write_text(
"Reference,Value,Footprint,PNM\n"
"R1,10k,0603,\n"
'"U9,U14,U26",TPD2E007DCKR,SOT23,TPD2E007DCKR\n'
)
g = build_graph(
root, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod",
mpn_col="Manufacturer Part Number",
)
assert "U13" not in (bom.read_text())
assert g.components["U13"].mpn == "TPD2E007DCKR"
assert g.schematic_fields["U13"]["cad_sheet"] == "codec.kicad_sch"
def test_hierarchical_empty_fields_do_not_invent_mpn(tmp_path: Path):
from backend.periscopex.graph import build_graph
root = _hier_root_with_child(tmp_path, _ic_symbol("U15", "", pnm="", mpn=""))
bom = tmp_path / "bom.csv"
bom.write_text("Reference,Value,PNM\nR1,10k,\n")
g = build_graph(
root, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod",
mpn_col="Manufacturer Part Number",
)
assert g.components["U15"].mpn in (None, "")
assert not (g.components["U15"].mpn or "").strip()
def test_pads_netlist_joins_sibling_hierarchical_sch(tmp_path: Path):
from backend.periscopex.graph import build_graph
_hier_root_with_child(
tmp_path,
_ic_symbol("U13", "TPD2E007DCKR", pnm="TPD2E007DCKR"),
)
asc = tmp_path / "netlist.asc"
asc.write_text(
"*PADS-PCB*\n*PART*\nU13 SOT23\nR1 0603\n*NET*\n"
"*SIGNAL* GND\nU13.1 R1.1\n*END*\n"
)
bom = tmp_path / "bom.csv"
bom.write_text("Reference,Value,PNM\nR1,10k,\n")
g = build_graph(
asc, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod",
mpn_col="Manufacturer Part Number",
)
assert g.components["U13"].mpn == "TPD2E007DCKR"
+618
View File
@@ -0,0 +1,618 @@
"""KiCad project folder ingest: *.kicad_pro + siblings, no netlist."""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from backend.periscopex.kicad_project import load_kicad_project, sniff_kicad_pro
from backend.periscopex.netlist_bundle import materialize_netlist_upload, sniff_netlist_kind
from backend.periscopex.parsers import parse_netlist_any
HUBAUDIO = Path("/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio")
_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))))
)
)
(symbol "Device:U"
(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))))
)
)
)
"""
def _sch(*body: str) -> str:
return (
"(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")"
+ _LIB_R
+ "".join(body)
+ "\n)\n"
)
def _resistor(ref: str, value: str) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:R")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(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 _ic(ref: str, value: str, pnm: str) -> str:
uid = "bbbbbbbb-bbbb-bbbb-bbbb-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:U")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(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))))
(property "PNM" "{pnm}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "u1"))
)
"""
def _pro(root_name: str) -> str:
return json.dumps({
"meta": {"filename": root_name.replace(".kicad_sch", ".kicad_pro")},
"schematic": {
"top_level_sheets": [{"filename": root_name, "name": "root"}],
},
})
def _write_project(folder: Path) -> None:
child = _sch(
_ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _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" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
)
folder.mkdir(parents=True, exist_ok=True)
(folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
(folder / "HubAudio.kicad_sch").write_text(root)
(folder / "Codec.kicad_sch").write_text(child)
(folder / "HubAudio.kicad_pcb").write_text(
'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
)
(folder / "netlist.asc").write_text(
"*PADS-PCB*\n*PART*\nU13 SOT23\nR1 0603\n*NET*\n"
"*SIGNAL* GND\nU13.1 R1.1\n*END*\n"
)
def test_sniff_kicad_pro_json():
body = _pro("HubAudio.kicad_sch").encode()
assert sniff_kicad_pro(body, "HubAudio.kicad_pro")
assert sniff_netlist_kind(body, "HubAudio.kicad_pro") == "kicad_pro"
def test_lone_kicad_pro_bytes_are_rejected(tmp_path: Path):
with pytest.raises(ValueError, match="da solo non basta"):
materialize_netlist_upload(
[("HubAudio.kicad_pro", _pro("HubAudio.kicad_sch").encode())],
tmp_path / "work",
)
def test_folder_next_to_pro_loads_sheets_and_pcb(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
files = [
(str(p.relative_to(tmp_path)), p.read_bytes())
for p in src.iterdir()
if p.is_file()
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert parsed.pcb is not None and parsed.pcb.name == "HubAudio.kicad_pcb"
assert "R1" in parts and "U9" in parts
assert "U13" not in parts
assert {p.name for p in parsed.extra_sch} == {"Codec.kicad_sch"}
def test_stale_pads_asc_ignored_when_pro_present(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
loaded = load_kicad_project(src)
assert loaded.root_sch.name == "HubAudio.kicad_sch"
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U13" not in parts
def test_disk_path_to_pro_copies_siblings(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
parsed = materialize_netlist_upload(
[(str(src / "HubAudio.kicad_pro"), (src / "HubAudio.kicad_pro").read_bytes())],
tmp_path / "work",
)
parts, _nets, _fmt = parse_netlist_any(parsed.root)
assert "U9" in parts and "U13" not in parts
assert parsed.pcb is not None
def test_graph_without_bom_csv_uses_schematic_pnm(tmp_path: Path):
from backend.periscopex.graph import build_graph
src = tmp_path / "HubAudio"
_write_project(src)
missing = tmp_path / "no-bom.csv"
g = build_graph(
src / "HubAudio.kicad_sch",
missing,
tmp_path / "ex",
tmp_path / "pat",
tmp_path / "mod",
)
assert g.components["U9"].mpn == "TPD2E007DCKR"
assert "U13" not in g.components
def test_empty_child_pnm_not_invented(tmp_path: Path):
from backend.periscopex.graph import build_graph
folder = tmp_path / "proj"
folder.mkdir()
child = _sch(
_ic("U15", "", ""),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _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" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
)
(folder / "p.kicad_pro").write_text(_pro("p.kicad_sch"))
(folder / "p.kicad_sch").write_text(root)
(folder / "Codec.kicad_sch").write_text(child)
g = build_graph(
folder / "p.kicad_sch",
tmp_path / "missing.csv",
tmp_path / "ex",
tmp_path / "pat",
tmp_path / "mod",
)
assert not (g.components["U15"].mpn or "").strip()
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
def test_hubaudio_folder_siblings_only():
loaded = load_kicad_project(HUBAUDIO)
assert loaded.pro.name == "HubAudio.kicad_pro"
assert loaded.root_sch.name == "HubAudio.kicad_sch"
names = {p.name for p in loaded.sheets}
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert loaded.pcb is not None and loaded.pcb.name == "HubAudio.kicad_pcb"
assert loaded.folder == HUBAUDIO.resolve()
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U13" not in parts
assert "U9" in parts
def test_api_folder_upload_no_csv(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": "ha"}).json()["id"]
src = tmp_path / "src"
_write_project(src)
files = [
("files", (p.name, p.read_bytes(), "application/octet-stream"))
for p in src.iterdir()
if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}
]
paths = [p.name for p in src.iterdir() if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}]
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files=files,
data={"paths": json.dumps(paths)},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["format"] == "kicad_sch"
assert body["pcb_saved"] is True
assert body["bom_saved"] is True
assert "U13" not in str(body)
assert body["parts"] >= 2
def _looks_like_kicad_pro(name: str, relative: str = "") -> bool:
"""Same rule as frontend looksLikeKicadPro (name and/or webkitRelativePath)."""
return any(".kicad_pro" in s.lower() for s in (name, relative, Path(relative or name).name))
def test_safari_stem_name_is_still_hubaudio_kicad_pro():
"""Live 2.60.6 checked only File.name.endsWith('.kicad_pro') and missed Safari."""
assert not "HubAudio".lower().endswith(".kicad_pro")
assert _looks_like_kicad_pro("HubAudio", "HubAudio/HubAudio.kicad_pro")
assert _looks_like_kicad_pro("HubAudio.kicad_pro", "")
assert sniff_netlist_kind(
_pro("HubAudio.kicad_sch").encode(), "HubAudio",
) == "kicad_pro"
def test_safari_stem_upload_writes_kicad_pro_suffix(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
pro = (src / "HubAudio.kicad_pro").read_bytes()
files = [
("HubAudio", pro),
("HubAudio.kicad_sch", (src / "HubAudio.kicad_sch").read_bytes()),
("Codec.kicad_sch", (src / "Codec.kicad_sch").read_bytes()),
("HubAudio.kicad_pcb", (src / "HubAudio.kicad_pcb").read_bytes()),
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
assert parsed.root.name == "HubAudio.kicad_sch"
assert any(p.name == "HubAudio.kicad_pro" for p in (tmp_path / "work").rglob("*"))
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "U9" in parts and "U13" not in parts
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
def test_real_hubaudio_kicad_pro_filename_and_folder_ingest(tmp_path: Path):
pro_path = HUBAUDIO / "HubAudio.kicad_pro"
assert pro_path.name == "HubAudio.kicad_pro"
data = pro_path.read_bytes()
assert sniff_kicad_pro(data, "HubAudio.kicad_pro")
assert sniff_netlist_kind(data, "HubAudio") == "kicad_pro"
files: list[tuple[str, bytes]] = []
for path in HUBAUDIO.iterdir():
if path.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}:
files.append((f"HubAudio/{path.name}", path.read_bytes()))
assert any(name.endswith("HubAudio.kicad_pro") for name, _ in files)
parsed = materialize_netlist_upload(files, tmp_path / "work")
assert parsed.root.name == "HubAudio.kicad_sch"
assert parsed.pcb is not None
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "U13" not in parts
# Mirrors periscope/src/frontend/src/lib/kicad-project-files.ts (folder walk + Next).
def _skip_walk_dir_name(name: str) -> bool:
n = name.rstrip("/").lower()
if not n or n.startswith("."):
return True
return bool(re.search(r"(-backups|\.pretty|3dmodels|__macosx|node_modules)$", n))
def _is_kicad_project_file_name(name: str) -> bool:
base = name.replace("\\", "/").rsplit("/", 1)[-1].lower()
return base.endswith((".kicad_pro", ".kicad_sch", ".kicad_pcb"))
def _skip_rel(rel: str) -> bool:
parts = [p for p in rel.replace("\\", "/").lower().split("/") if p]
return any(_skip_walk_dir_name(p) for p in parts)
def _details_step_ready(project_name: str, files: list[dict[str, str]]) -> bool:
if not project_name.strip():
return False
return any(_looks_like_kicad_pro(f.get("name", ""), f.get("webkitRelativePath", "")) for f in files)
def test_folder_walk_skips_history_and_keeps_project_dir_only():
"""Live 2.60.8 recursed handle.entries() into HubAudio/.history (~2000 files) so Next never enabled."""
names = [
"HubAudio.kicad_pro",
"HubAudio.kicad_sch",
"Codec.kicad_sch",
"HubAudio.kicad_pcb",
"HubAudio.kicad_prl",
"~HubAudio.kicad_pcb.lck",
".history",
".git",
"fp-lib-table",
]
kept = [n for n in names if _is_kicad_project_file_name(n)]
skipped_dirs = [n for n in names if _skip_walk_dir_name(n)]
assert kept == [
"HubAudio.kicad_pro",
"HubAudio.kicad_sch",
"Codec.kicad_sch",
"HubAudio.kicad_pcb",
]
assert ".history" in skipped_dirs and ".git" in skipped_dirs
assert _skip_rel("HubAudio/.history/objects/aa")
assert not _skip_rel("HubAudio/HubAudio.kicad_pro")
def test_details_next_enables_when_kicad_pro_is_in():
empty: list[dict[str, str]] = []
assert not _details_step_ready("HubAudio", empty)
assert not _details_step_ready(
"",
[{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
)
assert _details_step_ready(
"HubAudio",
[{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
)
assert _details_step_ready(
"HubAudio",
[{"name": "HubAudio", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}],
)
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
def test_real_hubaudio_recursive_tree_is_huge_project_dir_is_small():
tree = list(HUBAUDIO.rglob("*"))
project_dir = [
p
for p in HUBAUDIO.iterdir()
if p.is_file() and _is_kicad_project_file_name(p.name)
]
assert len(tree) > 100
assert (HUBAUDIO / ".history").is_dir()
assert any(p.name == "HubAudio.kicad_pro" for p in project_dir)
assert any(p.name.endswith(".kicad_sch") for p in project_dir)
assert any(p.name.endswith(".kicad_pcb") for p in project_dir)
assert all(".history" not in str(p) for p in project_dir)
assert len(project_dir) < 40
sch_names = {p.name for p in project_dir if p.suffix.lower() == ".kicad_sch"}
assert "HubAudio.kicad_sch" in sch_names
assert "USB.kicad_sch" in sch_names
assert "Codec.kicad_sch" in sch_names
assert "POWER.kicad_sch" in sch_names
assert len(sch_names) > 1
def _sheet_block(name: str, file: str) -> str:
return f"""
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "{name}" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "{file}" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
"""
def _write_hier_modules(folder: Path) -> None:
"""Root + USB/Codec/POWER siblings + sheets/nested.kicad_sch; junk in .history."""
folder.mkdir(parents=True, exist_ok=True)
nested_dir = folder / "sheets"
nested_dir.mkdir()
history = folder / ".history"
history.mkdir()
(history / "HubAudio.kicad_sch").write_text(_sch(_ic("U99", "FAKE", "FAKE")))
(history / "evil.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
usb = _sch(
_ic("U2", "CH340E", "CH340E"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd"))
""",
)
codec = _sch(
_ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
power = _sch(
_ic("U1", "SPX3819M5-L-3-3", "SPX3819M5-L-3-3"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"))
""",
)
nested = _sch(
_ic("U4", "PCA9534ARGTR", "PCA9534ARGTR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "ffffffffffffffffffffffffffffffffffffffff"))
""",
)
root = _sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
"""
+ _sheet_block("USB", "USB.kicad_sch")
+ _sheet_block("Codec", "Codec.kicad_sch")
+ _sheet_block("POWER", "POWER.kicad_sch")
+ _sheet_block("Nested", "sheets/nested.kicad_sch"),
)
(folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
(folder / "HubAudio.kicad_sch").write_text(root)
(folder / "USB.kicad_sch").write_text(usb)
(folder / "Codec.kicad_sch").write_text(codec)
(folder / "POWER.kicad_sch").write_text(power)
(nested_dir / "nested.kicad_sch").write_text(nested)
(folder / "HubAudio.kicad_pcb").write_text(
'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
)
def _sheetfiles_from_text(text: str) -> list[str]:
found: list[str] = []
for pat in (
r'\(\s*property\s+"Sheetfile"\s+"([^"]+)"',
r'\(\s*sheetfile\s+"([^"]+)"',
r'\(\s*file\s+"([^"]+\.kicad_sch)"',
r'file\s*=\s*"([^"]+\.kicad_sch)"',
):
found.extend(re.findall(pat, text, flags=re.I))
return list(dict.fromkeys(found))
def _join_sheet_rel(sch_rel: str, sheetfile: str) -> str | None:
file = sheetfile.replace("\\", "/").strip()
if not file or file.startswith("/") or ".." in file.split("/"):
return None
parent = sch_rel.replace("\\", "/").rsplit("/", 1)[0] if "/" in sch_rel.replace("\\", "/") else ""
joined = f"{parent}/{file}" if parent else file
if _skip_rel(joined):
return None
return joined
def _files_beside(incoming: list[dict[str, str]]) -> list[dict[str, str]]:
"""Same keep rule as frontend filesBesideKicadPro (all sch under project dir)."""
hits = [
f
for f in incoming
if _looks_like_kicad_pro(f["name"], f.get("webkitRelativePath", ""))
and not _skip_rel(f.get("webkitRelativePath") or f["name"])
]
if not hits:
return []
pro_rel = (hits[0].get("webkitRelativePath") or hits[0]["name"]).replace("\\", "/")
pro_dir = pro_rel.rsplit("/", 1)[0] if "/" in pro_rel else ""
kept: list[dict[str, str]] = []
for f in incoming:
rel = (f.get("webkitRelativePath") or f["name"]).replace("\\", "/")
if _skip_rel(rel):
continue
parent = rel.rsplit("/", 1)[0] if "/" in rel else ""
under = parent == pro_dir or (
bool(pro_dir) and (parent == pro_dir or parent.startswith(pro_dir + "/"))
) or (not parent and not pro_dir)
if not under:
continue
base = rel.rsplit("/", 1)[-1].lower()
if not (
".kicad_pro" in rel.lower()
or base.endswith((".kicad_sch", ".kicad_pcb"))
):
continue
kept.append(f)
return kept
def test_sheetfiles_from_root_text_usb_codec_power():
text = (
_sheet_block("USB", "USB.kicad_sch")
+ _sheet_block("Codec", "Codec.kicad_sch")
+ _sheet_block("POWER", "POWER.kicad_sch")
+ '(file "sheets/nested.kicad_sch")'
+ 'file="also.kicad_sch"'
)
names = _sheetfiles_from_text(text)
assert "USB.kicad_sch" in names
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert "sheets/nested.kicad_sch" in names
assert "also.kicad_sch" in names
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "USB.kicad_sch") == "HubAudio/USB.kicad_sch"
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "sheets/nested.kicad_sch") == "HubAudio/sheets/nested.kicad_sch"
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", ".history/x.kicad_sch") is None
assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "../escape.kicad_sch") is None
def test_keep_every_sibling_sch_not_only_root():
incoming = [
{"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"},
{"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/HubAudio.kicad_sch"},
{"name": "USB.kicad_sch", "webkitRelativePath": "HubAudio/USB.kicad_sch"},
{"name": "Codec.kicad_sch", "webkitRelativePath": "HubAudio/Codec.kicad_sch"},
{"name": "POWER.kicad_sch", "webkitRelativePath": "HubAudio/POWER.kicad_sch"},
{"name": "nested.kicad_sch", "webkitRelativePath": "HubAudio/sheets/nested.kicad_sch"},
{"name": "HubAudio.kicad_pcb", "webkitRelativePath": "HubAudio/HubAudio.kicad_pcb"},
{"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/.history/HubAudio.kicad_sch"},
]
kept = _files_beside(incoming)
names = { (f.get("webkitRelativePath") or f["name"]).rsplit("/", 1)[-1] for f in kept }
rels = { f.get("webkitRelativePath") or f["name"] for f in kept }
assert "HubAudio.kicad_sch" in names
assert "USB.kicad_sch" in names
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert "nested.kicad_sch" in names
assert names != {"HubAudio.kicad_sch"}
assert "HubAudio/.history/HubAudio.kicad_sch" not in rels
def test_hier_modules_and_nested_sheet_history_ignored(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_hier_modules(src)
loaded = load_kicad_project(src)
names = {p.name for p in loaded.sheets}
assert names == {
"HubAudio.kicad_sch",
"USB.kicad_sch",
"Codec.kicad_sch",
"POWER.kicad_sch",
"nested.kicad_sch",
}
assert all(".history" not in p.parts for p in loaded.sheets)
assert loaded.pro.name == "HubAudio.kicad_pro"
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U2" in parts and "U9" in parts and "U1" in parts and "U4" in parts
assert "U99" not in parts
files = [
(str(p.relative_to(tmp_path)), p.read_bytes())
for p in src.rglob("*")
if p.is_file() and p.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}
and ".history" not in p.parts
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
extra = {p.name for p in parsed.extra_sch}
assert extra == {"USB.kicad_sch", "Codec.kicad_sch", "POWER.kicad_sch", "nested.kicad_sch"}
copied = {p.name for p in (tmp_path / "work").rglob("*.kicad_sch")}
assert "nested.kicad_sch" in copied
assert not list((tmp_path / "work").rglob(".history/**/*"))
def test_history_kicad_pro_is_not_the_project(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_hier_modules(src)
loaded = load_kicad_project(src)
assert ".history" not in loaded.pro.parts
assert loaded.pro.parent == src.resolve()
+122
View File
@@ -0,0 +1,122 @@
"""LED forward-current check — Ohm's-law over the graph against the LED's rating.
Locks in:
1. An undersized resistor (over-current) is an ERROR with source set.
2. A properly sized resistor produces nothing.
3. Resistance strings like "5.6K" parse correctly (not 5.6 ohm).
4. Unknown rail voltage is not guessed into an ERROR.
"""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
ResistorSpecs,
SimpleComponentSpecs,
PinConnection,
)
from backend.periscopex.led_current_check import check_led_current, _parse_resistance
def _led(values, pins, subtype="discrete.led.rgb"):
return Component(
reference="D1", value="RGB", footprint="",
component_type=ComponentType.DISCRETE, component_subtype=subtype,
mpn="LEDX",
pins=pins,
specs=SimpleComponentSpecs(specs_type="discrete", component_subtype=subtype, values=values),
)
def _res(ref, ohms_str, pins, value_ohms=None):
specs = ResistorSpecs(value_ohms=value_ohms, value_formatted=ohms_str) if value_ohms is not None else None
return Component(reference=ref, value=ohms_str, footprint="",
component_type=ComponentType.RESISTOR, mpn=ref, pins=pins, specs=specs)
def _driver(ref, pins):
return Component(reference=ref, value="", footprint="",
component_type=ComponentType.DISCRETE, mpn=ref, pins=pins)
def _graph(components, nets):
"""nets: {name: (net_type, voltage, [(ref, pin)])}"""
net_objs = {}
for name, (ntype, volt, conns) in nets.items():
net_objs[name] = Net(
name=name, net_type=ntype, voltage=volt,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
return DesignGraph(components=components, nets=net_objs)
def _rgb_graph(green_resistor):
led = _led(
{"forward_voltage_green_v": "2.8V", "forward_current_per_channel_a": "13mA",
"common_polarity": 1.0},
{"A": "+5V", "G": "NetD1_G"},
)
q = _driver("Q1", {"3": "NetQ_D"})
comps = {"D1": led, "R1": green_resistor, "Q1": q}
nets = {
"+5V": (NetType.POWER, 5.0, [("D1", "A")]),
"NetD1_G": (NetType.SIGNAL, None, [("D1", "G"), ("R1", "2")]),
"NetQ_D": (NetType.SIGNAL, None, [("R1", "1"), ("Q1", "3")]),
}
return _graph(comps, nets)
def test_over_current_is_error():
# 100 ohm from 5 V, Vf 2.8 -> 22 mA > 13 mA rating.
g = _rgb_graph(_res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0))
findings = check_led_current(g)
assert len(findings) == 1
f = findings[0]
assert f.status == "ERROR" and f.source == "led_current_check" and f.source_page is None
assert f.designator == "D1" and "green channel" in f.finding
def test_proper_resistor_no_finding():
# 5.6K (string only, no typed value_ohms) -> ~0.4 mA, safe.
g = _rgb_graph(_res("R1", "5.6K", {"2": "NetD1_G", "1": "NetQ_D"}))
assert check_led_current(g) == []
def test_unknown_rail_no_error():
# Anode net has no voltage tag and the resistor far net is untagged -> skip.
led = _led(
{"forward_voltage_green_v": "2.8V", "forward_current_per_channel_a": "13mA"},
{"A": "NetD1_A", "G": "NetD1_G"},
)
r = _res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0)
q = _driver("Q1", {"3": "NetQ_D"})
g = _graph(
{"D1": led, "R1": r, "Q1": q},
{
"NetD1_A": (NetType.SIGNAL, None, [("D1", "A")]),
"NetD1_G": (NetType.SIGNAL, None, [("D1", "G"), ("R1", "2")]),
"NetQ_D": (NetType.SIGNAL, None, [("R1", "1"), ("Q1", "3")]),
},
)
assert check_led_current(g) == []
def test_no_rating_skipped():
g = _rgb_graph(_res("R1", "100R", {"2": "NetD1_G", "1": "NetQ_D"}, value_ohms=100.0))
# Strip the rating off the LED specs.
g.components["D1"].specs.values = {"forward_voltage_green_v": "2.8V"}
assert check_led_current(g) == []
def test_parse_resistance():
assert _parse_resistance("5.6K") == 5600.0
assert _parse_resistance("5K6") == 5600.0
assert _parse_resistance("150R") == 150.0
assert _parse_resistance("4R7") == 4.7
assert _parse_resistance("1M") == 1_000_000.0
assert _parse_resistance("0") == 0.0
assert _parse_resistance("100") == 100.0
+98
View File
@@ -0,0 +1,98 @@
"""Lifecycle from distributor payload.
Favor: DigiKey Obsolete → PE-LF-001 WARNING and uses ProductSubstitutions;
NRND → PE-LF-002 INFO; explicit RoHS Non-Compliant → PE-LF-003.
Against: Active is silent; RoHS Not Applicable is not a fail; missing
catalog row is silent; no substitution key means no invented replacement.
"""
from __future__ import annotations
from backend.periscopex.lifecycle import (
check_lifecycle,
parse_distributor_product,
)
from backend.periscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
def _graph(mpn="PARTX"):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn=mpn,
pins={"1": "VDD"},
)
return DesignGraph(
components={"U1": u},
nets={"VDD": Net(name="VDD", net_type=NetType.POWER, pins=[
PinConnection(component_ref="U1", pin_number="1"),
])},
)
def test_obsolete_is_warning_and_uses_distributor_replacement():
rec = parse_distributor_product("ABC", {
"ManufacturerProductNumber": "ABC",
"ProductStatus": "Obsolete",
"RoHSStatus": "RoHS3 Compliant",
"QuantityAvailable": 12,
"ManufacturerLeadWeeks": "8",
"ProductSubstitutions": [{"ManufacturerProductNumber": "ABC-B"}],
})
assert rec.lifecycle == "eol"
assert rec.rohs_compliant is True
assert rec.stock == 12
assert rec.replacement == "ABC-B"
findings = check_lifecycle(_graph("ABC"), {"ABC": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PE-LF-001"
assert findings[0].status == "WARNING"
assert findings[0].source == "lifecycle_check"
assert "ABC-B" in (findings[0].recommendation or "")
def test_nrnd_is_info():
rec = parse_distributor_product("N1", {"ProductStatus": "Not For New Designs"})
assert rec.lifecycle == "nrnd"
findings = check_lifecycle(_graph("N1"), {"N1": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PE-LF-002"
assert findings[0].status == "INFO"
def test_explicit_rohs_non_compliant_is_warning():
rec = parse_distributor_product("R1", {
"ProductStatus": "Active",
"RoHSStatus": "Non-Compliant",
})
assert rec.rohs_compliant is False
findings = check_lifecycle(_graph("R1"), {"R1": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PE-LF-003"
assert findings[0].status == "WARNING"
def test_active_is_silent():
rec = parse_distributor_product("A1", {"ProductStatus": "Active"})
assert rec.lifecycle == "active"
assert check_lifecycle(_graph("A1"), {"A1": rec}) == []
def test_rohs_not_applicable_is_not_a_fail():
rec = parse_distributor_product("R2", {
"ProductStatus": "Active",
"RoHSStatus": "Not Applicable",
})
assert rec.rohs_compliant is None
assert check_lifecycle(_graph("R2"), {"R2": rec}) == []
def test_missing_catalog_and_missing_substitute_are_not_guessed():
assert check_lifecycle(_graph("NOPE"), {}) == []
rec = parse_distributor_product("EOLX", {"ProductStatus": "Discontinued"})
assert rec.lifecycle == "eol"
assert rec.replacement is None
findings = check_lifecycle(_graph("EOLX"), {"EOLX": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PE-LF-001"
assert "ABC-B" not in (findings[0].recommendation or "")
assert rec.replacement is None
+115
View File
@@ -0,0 +1,115 @@
"""Prove native review parse/tools match inherited PinScope before switching the live loop."""
from __future__ import annotations
import json
from backend.periscopex.constraints_lookup import match_constraints
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import DesignGraph
from backend.periscopex.review_parse import parse_submit_review
from backend.periscopex.validate import _load_datasheets, _match_constraints, _parse_review
from backend.periscopex.validation_tools import execute_tool as inherited_execute
from backend.periscopex.review_tools import execute_tool as native_execute
from tests.paths import SIMPLE_PROJECT
_PAYLOAD = {
"findings": [
{
"finding": "FB5 floating",
"why": "FB5 is NC; DCDC5 SW is loaded.",
"status": "ERROR",
"source_page": 12,
"source_quote": "Connect FB5 to the output sense node.",
"recommendation": "Connect FB5.",
},
{
"finding": "no quote",
"why": "maybe missing cap",
"status": "ERROR",
"source_page": 3,
"source_quote": "",
},
],
"checked_areas": ["power", "decoupling"],
}
def test_native_parse_matches_inherited():
inherited = _parse_review(_PAYLOAD, "U16", "AXP2101")
native = parse_submit_review(_PAYLOAD, "U16", "AXP2101")
assert len(native.findings) == len(inherited.findings)
assert native.checked_areas == inherited.checked_areas
for a, b in zip(native.findings, inherited.findings, strict=True):
assert a.model_dump() == b.model_dump()
def test_native_llm_finding_is_review_and_recommended_not_error():
native = parse_submit_review(_PAYLOAD, "U16", "AXP2101")
f = native.findings[0]
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.facts == "FB5 floating"
assert f.requirement
assert f.status != "ERROR"
f2 = native.findings[1]
complete_finding(f2)
assert f2.status != "ERROR"
assert f2.why.startswith("Unverified:")
def test_match_constraints_matches_inherited():
datasheets = _load_datasheets(SIMPLE_PROJECT / "extracted")
if not datasheets:
datasheets = _load_datasheets(SIMPLE_PROJECT / "datasheets" / "extracted")
mpns = list(datasheets)[:5] or ["MSPM0G3507SPTR"]
for mpn in mpns:
assert match_constraints(mpn, datasheets) == _match_constraints(mpn, datasheets)
assert match_constraints(None, datasheets) is None
def test_native_graph_tools_match_inherited_on_simple_project():
graph = DesignGraph.model_validate(
json.loads((SIMPLE_PROJECT / "design_graph.json").read_text())
)
cmap = {}
native_txt, _ = native_execute(
graph, cmap, "get_net_for_pin", {"designator": "U3", "pin": "1"},
)
inherited_txt, _ = inherited_execute(
graph, cmap, "get_net_for_pin", {"designator": "U3", "pin": "1"},
)
assert native_txt == inherited_txt
n2, _ = native_execute(
graph, cmap, "find_connected_components",
{"designator": "U3", "pin": "1", "designator_filter": "C"},
)
i2, _ = inherited_execute(
graph, cmap, "find_connected_components",
{"designator": "U3", "pin": "1", "designator_filter": "C"},
)
assert n2 == i2
def test_native_session_does_not_import_pinscope_loop():
import ast
from pathlib import Path
import backend.services.review_session as rs
tree = ast.parse(Path(rs.__file__).read_text())
imported = [
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
]
assert "backend.periscopex.validate" not in imported
assert "backend.periscopex.validation_tools" not in imported
assert "backend.services.llm" in imported
assert "backend.periscopex.review_tools" in imported
assert "backend.periscopex.review_parse" in imported
from backend.services import validation as val
import backend.services.review_session as rs
assert val.review_ic_async is rs.review_ic_async
+101
View File
@@ -0,0 +1,101 @@
"""NC pin connectivity check."""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.nc_pin_check import check_nc_pins
def test_nc_pin_on_active_net_warns():
g = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="",
component_type=ComponentType.IC, mpn="PART",
pins={"1": "SIG", "2": "GND"},
),
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "SIG", "2": "GND"},
),
},
nets={
"SIG": Net(
name="SIG", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="R1", pin_number="1"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="R1", pin_number="2"),
],
),
},
)
cmap = {
"PART": ComponentConstraints(
mpn="PART",
pintable=[Pin(number="1", name="NC"), Pin(number="2", name="GND")],
absolute_maximum_ratings=[],
rules=[],
),
}
findings = check_nc_pins(g, cmap)
assert len(findings) == 1
assert findings[0].rule_id == "PE-NC-001"
assert findings[0].net == "SIG"
def test_nc_pin_on_nc_net_silent():
g = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="",
component_type=ComponentType.IC, mpn="PART",
pins={"1": "NC"},
),
},
nets={
"NC": Net(
name="NC", net_type=NetType.UNKNOWN,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
cmap = {
"PART": ComponentConstraints(
mpn="PART",
pintable=[Pin(number="1", name="NC")],
absolute_maximum_ratings=[],
rules=[],
),
}
assert check_nc_pins(g, cmap) == []
def test_no_pintable_silent():
g = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="",
component_type=ComponentType.IC, mpn="PART",
pins={"1": "SIG"},
),
},
nets={},
)
assert check_nc_pins(g, {}) == []
+263
View File
@@ -0,0 +1,263 @@
"""Multi-file / zip KiCad schematic ingest."""
from __future__ import annotations
import io
import zipfile
from pathlib import Path
import pytest
from backend.periscopex.netlist_bundle import (
find_kicad_pcb,
materialize_netlist_upload,
sniff_netlist_kind,
)
from backend.periscopex.parsers import parse_netlist_any, validate_netlist
_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) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:R")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(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 _root_with_child() -> tuple[str, str]:
child = _sch(
_resistor("C1", "100n"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _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))))
)
""",
)
return root, child
def test_sniff_rejects_pcb_as_netlist():
assert sniff_netlist_kind(b"(kicad_pcb (version 1)\n") == "kicad_pcb"
assert sniff_netlist_kind(b"PK\x03\x04rest") == "zip"
assert sniff_netlist_kind(b"(kicad_sch (version 1)") == "kicad_sch"
assert sniff_netlist_kind(b"*PADS-PCB*\n*PART*\n") == "pads"
def test_root_alone_missing_child_explains_multi_file(tmp_path: Path):
from backend.periscopex.parsers_kicad import parse_kicad
root, _child = _root_with_child()
parsed = materialize_netlist_upload(
[("root.kicad_sch", root.encode())],
tmp_path / "work",
)
with pytest.raises(ValueError, match="child.kicad_sch"):
parse_kicad(parsed.root)
def test_multiple_sch_files_parse(tmp_path: Path):
root, child = _root_with_child()
parsed = materialize_netlist_upload(
[
("root.kicad_sch", root.encode()),
("child.kicad_sch", child.encode()),
],
tmp_path / "work",
)
parts, nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "R1" in parts and "C1" in parts
assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"]
assert validate_netlist(parts, nets) == []
assert parsed.pcb is None
def test_zip_with_nested_folder_and_pcb(tmp_path: Path):
root, child = _root_with_child()
pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("board/root.kicad_sch", root)
zf.writestr("board/child.kicad_sch", child)
zf.writestr("board/board.kicad_pcb", pcb)
parsed = materialize_netlist_upload(
[("board.zip", buf.getvalue())],
tmp_path / "work",
)
parts, nets, _fmt = parse_netlist_any(parsed.root)
assert "R1" in parts and "C1" in parts
assert parsed.pcb is not None
assert find_kicad_pcb(parsed.work_dir) is not None
def test_zip_slip_is_rejected(tmp_path: Path):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("../escape.kicad_sch", _sch(_resistor("R1", "1k")))
with pytest.raises(ValueError, match="Rejected"):
materialize_netlist_upload(
[("bad.zip", buf.getvalue())],
tmp_path / "work",
)
def test_api_accepts_zip_and_companion_sheets(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": "kicad"}).json()["id"]
root, child = _root_with_child()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("root.kicad_sch", root)
zf.writestr("child.kicad_sch", child)
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files={"file": ("board.zip", buf.getvalue(), "application/zip")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["parts"] == 2
assert body["format"] == "kicad_sch"
storage = client.app.state.storage
prefix = f"users/local/projects/{pid}/uploads/"
assert storage.exists(prefix + "netlist.kicad_sch")
assert storage.exists(prefix + "child.kicad_sch")
def test_api_accepts_multiple_sch_files(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": "multi"}).json()["id"]
root, child = _root_with_child()
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files=[
("files", ("root.kicad_sch", root.encode(), "text/plain")),
("files", ("child.kicad_sch", child.encode(), "text/plain")),
],
data={"paths": '["root.kicad_sch", "child.kicad_sch"]'},
)
assert resp.status_code == 200, resp.text
assert resp.json()["parts"] == 2
def test_zip_pipeline_workspace_reparses_hierarchy(tmp_path: Path):
"""Upload → storage → local workspace layout must still see child sheets."""
from fastapi.testclient import TestClient
from backend.main import app
from backend.periscopex.parsers import parse_netlist_any
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "pipe"}).json()["id"]
root, child = _root_with_child()
root_nested = root.replace(
'Sheetfile" "child.kicad_sch"',
'Sheetfile" "sheets/child.kicad_sch"',
)
pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
bom = (
b"Reference,Value,Manufacturer Part Number\n"
b"R1,10k,RC0603FR-0710KL\n"
b"C1,100n,CL10B104KB8NNNC\n"
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("proj/root.kicad_sch", root_nested)
zf.writestr("proj/sheets/child.kicad_sch", child)
zf.writestr("proj/board.kicad_pcb", pcb)
zf.writestr("proj/bom.csv", bom)
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files={"file": ("board.zip", buf.getvalue(), "application/zip")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["parts"] == 2
assert body["sheets"] == 2
assert body["pcb_saved"] is True
assert body["bom_saved"] is True
storage = client.app.state.storage
prefix = f"users/local/projects/{pid}/uploads/"
ws = tmp_path / "ws" / "uploads"
ws.mkdir(parents=True)
for key in storage.list_recursive(prefix):
rel = key[len(prefix):]
dest = ws / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(storage.read_bytes(key))
parts, nets, fmt = parse_netlist_any(ws / "netlist.kicad_sch")
assert fmt == "kicad_sch"
assert "R1" in parts and "C1" in parts
assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"]
assert (ws / "pcb.kicad_pcb").is_file()
assert (ws / "bom.csv").is_file()
meta = client.get(f"/api/projects/{pid}").json()
assert meta["has_pcb"] is True
assert meta["has_bom"] is True
assert meta["has_netlist"] is True
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
with pytest.raises(ValueError, match="circuito stampato|board"):
materialize_netlist_upload(
[("board.kicad_pcb", b"(kicad_pcb (version 1)\n")],
tmp_path / "work",
)
+113
View File
@@ -0,0 +1,113 @@
"""PADS-PCB netlist parser — section-marker robustness.
Regression coverage for EasyEDA Pro exports, which decorate section headers
with trailing labels (``*PART* ITEMS``) and append a ``*MISC*
ATTRIBUTE VALUES`` block after the connectivity. Earlier versions did
exact-string matching on section markers, so:
1. The decorated ``*PART*`` header was not recognised — no real parts
were collected.
2. The unrecognised ``*MISC*`` block leaked into the net section, where
``"Datasheet" https://...`` and ``"Footprint" C0603_L1.6-W0.8-H0.8``
tokens were misparsed as ``ref.pin`` pin connections, polluting the
graph with phantom components.
"""
from __future__ import annotations
from backend.periscopex.parsers import parse_netlist
EASYEDA_PRO_NETLIST = """\
!PADS-POWERPCB-V9.0-MILS-CP936! Created by EasyEDA Pro V2.2.47.7
*REMARK* Smart Gas Cap_1 -- 2026-04-25 14:52:03
*REMARK*
*PART* ITEMS
U1 RP2040@LQFN-56_L7.0-W7.0-P0.4-EP
C1 CAI0603X7R105K250JT@C0603
R1 FRC0603J105 TS@R0603
*NET*
*SIGNAL* GND
U1.19 C1.1 R1.1
*SIGNAL* +3V3
U1.1 C1.2 R1.2
*MISC* MISCELLANEOUS PARAMETERS
ATTRIBUTE VALUES
{
PART U1
{
"Manufacturer Part" RP2040
"Datasheet" https://item.szlcsc.com/datasheet/RP2040/2392.html
"Footprint" LQFN-56_L7.0-W7.0-P0.4-EP
"3D Model Title" LQFN-56_L7.0-W7.0-P0.4-EP
"Description" Voltage Range:2.7V~3.6V Current:100mA
}
PART C1
{
"Datasheet" https://item.szlcsc.com/datasheet/CAI0603X7R105K250JT/51675802.html
"3D Model Title" C0603_L1.6-W0.8-H0.8
}
}
*END* OF ASCII OUTPUT FILE
"""
def test_easyeda_pro_misc_section_does_not_pollute_nets(tmp_path):
"""*MISC* attribute block must not leak into net connectivity."""
netlist_path = tmp_path / "netlist.asc"
netlist_path.write_text(EASYEDA_PRO_NETLIST)
known_refs = {"U1", "C1", "R1"}
parts, nets = parse_netlist(netlist_path, known_refs=known_refs)
assert set(parts.keys()) == {"U1", "C1", "R1"}, (
f"phantom parts leaked from *MISC* block: "
f"{set(parts.keys()) - {'U1', 'C1', 'R1'}}"
)
assert set(nets.keys()) == {"GND", "+3V3"}
all_refs = {ref for pin_list in nets.values() for ref, _ in pin_list}
assert all_refs <= known_refs, (
f"phantom refs in nets from *MISC* misparse: {all_refs - known_refs}"
)
def test_decorated_part_header_is_recognised(tmp_path):
"""``*PART* ITEMS`` (with trailing label) must enter the part section."""
netlist_path = tmp_path / "netlist.asc"
netlist_path.write_text(
"*PART* ITEMS\n"
"U1 RP2040@LQFN-56\n"
"C1 CAP@C0603\n"
"*NET*\n"
"*SIGNAL* GND\n"
"U1.19 C1.1\n"
"*END*\n"
)
parts, nets = parse_netlist(netlist_path)
assert parts == {"U1": "RP2040@LQFN-56", "C1": "CAP@C0603"}
assert nets == {"GND": [("U1", "19"), ("C1", "1")]}
def test_part_section_without_trailing_label_still_parses(tmp_path):
"""Plain ``*PART*`` header (no trailing label) must still be recognised."""
netlist_path = tmp_path / "netlist.asc"
netlist_path.write_text(
"*PADS-PCB*\n"
"*PART*\n"
"U1 RP2040@LQFN-56\n"
"*NET*\n"
"*SIGNAL* GND\n"
"U1.1\n"
"*END*\n"
)
parts, nets = parse_netlist(netlist_path)
assert parts == {"U1": "RP2040@LQFN-56"}
assert nets == {"GND": [("U1", "1")]}
+277
View File
@@ -0,0 +1,277 @@
"""Per-IC normalize pass — schema + validator behavior.
Locks in the rules added after the staging-project audit:
1. The reviewer's `status` is shown to the normalize LLM, which may only
re-grade *downward*: a deterministic clamp forbids raising any finding
above the reviewer's calibrated severity, caps `Unverified:` findings
at WARNING, and preserves the `Unverified:` prefix. (This is the fix
for the U2-001 false positive, where normalize laundered a hedged
WARNING into a confident ERROR.)
2. Self-cancelling findings can be dropped via a `dropped` array
(index + reason) and are then removed from the report entirely.
3. Merges (`len(merged_from) > 1`) require a non-empty `single_fix`
describing the one atomic component/net change that resolves all
members; missing `single_fix` un-merges back to per-index originals.
"""
from __future__ import annotations
import json
from backend.periscopex.models import Finding
from backend.services.normalize_findings import (
SUBMIT_NORMALIZED_SCHEMA,
_build_normalized,
_serialize_findings_for_prompt,
)
def _f(idx: int, status: str = "WARNING", why: str = "w") -> Finding:
return Finding(
designator="U3",
mpn="X",
finding=f"finding {idx}",
why=why,
status=status,
recommendation="",
source_page=idx,
source_quote="",
reference="",
)
def test_serialize_for_prompt_shows_reviewer_severity():
"""Normalize IS shown the reviewer's severity so it can re-grade
downward from it — the deterministic clamp enforces downgrade-only."""
findings = [_f(1, status="ERROR"), _f(2, status="INFO")]
out = _serialize_findings_for_prompt(findings)
parsed = json.loads(out)
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="PE-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 == "PE-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
dropped_item = props["dropped"]["items"]
assert dropped_item["required"] == ["index", "reason"]
finding_props = props["findings"]["items"]["properties"]
assert "single_fix" in finding_props
def test_drop_self_cancelling_finding_removes_from_kept_list():
originals = [_f(1), _f(2, why="satisfies the spec via C24")]
raw_findings = [{
"merged_from": [1], "finding": "f1", "why": "w",
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
}]
raw_dropped = [{"index": 2, "reason": "self-cancelling: C24 satisfies spec"}]
built = _build_normalized(raw_findings, raw_dropped, originals)
assert built is not None
kept, dropped = built
assert len(kept) == 1
assert kept[0].finding == "f1"
assert len(dropped) == 1
assert dropped[0]["index"] == 2
assert "C24" in dropped[0]["reason"]
# Original finding is preserved in the dropped record for forensics.
assert dropped[0]["original_finding"]["finding"] == "finding 2"
def test_drop_with_empty_reason_is_rejected():
originals = [_f(1), _f(2)]
raw_findings = [{
"merged_from": [1], "finding": "f1", "why": "w",
"status": "WARNING", "recommendation": "", "change_rationale": "unchanged",
}]
raw_dropped = [{"index": 2, "reason": ""}]
assert _build_normalized(raw_findings, raw_dropped, originals) is None
def test_drop_and_keep_cannot_cover_same_index():
"""Double-coverage (drop + keep both name index 1) must be rejected."""
originals = [_f(1), _f(2)]
raw_findings = [
{"merged_from": [1], "finding": "f1", "why": "w", "status": "INFO",
"recommendation": "", "change_rationale": "unchanged"},
{"merged_from": [2], "finding": "f2", "why": "w", "status": "INFO",
"recommendation": "", "change_rationale": "unchanged"},
]
raw_dropped = [{"index": 1, "reason": "shouldn't also be kept"}]
assert _build_normalized(raw_findings, raw_dropped, originals) is None
def test_coverage_gap_is_rejected():
"""Every original index must end up somewhere (kept, merged, or dropped)."""
originals = [_f(1), _f(2)]
raw_findings = [{
"merged_from": [1], "finding": "f1", "why": "w",
"status": "INFO", "recommendation": "", "change_rationale": "unchanged",
}]
# Index 2 is uncovered.
assert _build_normalized(raw_findings, [], originals) is None
def test_merge_without_single_fix_unmerges_to_originals():
"""A merge whose model omitted `single_fix` is not valid — break it
apart and surface the per-index originals (severity preserved)."""
originals = [_f(1, status="WARNING"), _f(2, status="INFO")]
raw_findings = [{
"merged_from": [1, 2],
"finding": "merged into ERROR",
"why": "combined harm",
"status": "ERROR",
"recommendation": "",
"change_rationale": "merged",
# single_fix intentionally omitted
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, dropped = built
assert len(dropped) == 0
assert len(kept) == 2
# Originals are preserved verbatim — severity not laundered up.
assert kept[0].status == "WARNING"
assert kept[1].status == "INFO"
def test_merge_severity_clamped_to_strongest_member():
"""A merge cannot exceed the highest original severity among members.
Merging WARNING + INFO and asking for ERROR clamps to WARNING."""
originals = [_f(1, status="WARNING"), _f(2, status="INFO")]
raw_findings = [{
"merged_from": [1, 2],
"finding": "single root cause",
"why": "combined harm",
"status": "ERROR", # over-graded — must clamp to WARNING
"recommendation": "remove R1",
"change_rationale": "merged (atomic)",
"single_fix": "remove R1 from the VIN path",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert len(kept) == 1
assert kept[0].status == "WARNING" # clamped down from the proposed ERROR
assert kept[0].finding == "single root cause"
def test_merge_keeps_error_when_a_member_was_error():
"""The clamp is a ceiling, not a cap-to-WARNING: an ERROR member lets
the merged finding stay ERROR."""
originals = [_f(1, status="ERROR"), _f(2, status="WARNING")]
raw_findings = [{
"merged_from": [1, 2],
"finding": "single root cause",
"why": "combined harm",
"status": "ERROR",
"recommendation": "fix",
"change_rationale": "merged",
"single_fix": "rewire X to Z",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert kept[0].status == "ERROR"
def test_normalize_cannot_upgrade_single_finding():
"""The U2-001 bug: reviewer graded WARNING, normalize must not promote
it to ERROR even on a passthrough (len-1 group)."""
originals = [_f(1, status="WARNING")]
raw_findings = [{
"merged_from": [1],
"finding": "f1",
"why": "w",
"status": "ERROR", # attempted upgrade
"recommendation": "",
"change_rationale": "graded ERROR per rubric",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert kept[0].status == "WARNING" # upgrade rejected
def test_unverified_finding_capped_at_warning_and_prefix_preserved():
"""A finding whose `why` starts with 'Unverified:' can never be ERROR,
and the prefix survives even if the model rewrote `why` without it."""
originals = [_f(1, status="WARNING", why="Unverified: abs-max for PA14 not confirmed")]
raw_findings = [{
"merged_from": [1],
"finding": "PA14 overvoltage",
"why": "The 5V output exceeds the 3.6V abs-max and will damage the MCU",
"status": "ERROR", # confident upgrade + dropped the Unverified prefix
"recommendation": "level shift",
"change_rationale": "graded ERROR",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert kept[0].status == "WARNING"
assert kept[0].why.lower().startswith("unverified:")
def test_unchanged_passthrough_with_single_index_does_not_require_single_fix():
"""`single_fix` is only required for true merges (len > 1)."""
originals = [_f(1, status="ERROR")]
raw_findings = [{
"merged_from": [1],
"finding": "passthrough",
"why": "w",
"status": "WARNING", # normalize re-graded down
"recommendation": "",
"change_rationale": "downgraded per rubric",
}]
built = _build_normalized(raw_findings, [], originals)
assert built is not None
kept, _ = built
assert len(kept) == 1
assert kept[0].status == "WARNING"
def test_all_findings_dropped_is_valid():
"""An IC where every finding was self-cancelling produces an empty
report — that is a valid outcome, not a coverage failure."""
originals = [_f(1, why="X satisfies spec"), _f(2, why="Y is in the right place")]
raw_findings = []
raw_dropped = [
{"index": 1, "reason": "X meets spec"},
{"index": 2, "reason": "Y is the input cap"},
]
built = _build_normalized(raw_findings, raw_dropped, originals)
assert built is not None
kept, dropped = built
assert kept == []
assert len(dropped) == 2
+455
View File
@@ -0,0 +1,455 @@
"""Supply decoupling and I2C/reset pull-up checks — graph topology only."""
from __future__ import annotations
from backend.periscopex.graph import _infer_net_properties
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.passive_rail_check import (
check_i2c_pullups,
check_reset_pullups,
check_supply_decoupling,
)
def test_ki_cad_voltage_prefix_is_power():
ntype, volts = _infer_net_properties("3V3_DIGITAL")
assert ntype == NetType.POWER
assert volts == 3.3
ntype, volts = _infer_net_properties("1V8_SI4684")
assert ntype == NetType.POWER
assert volts == 1.8
ntype, _ = _infer_net_properties("I2C1-SCL-3V3")
assert ntype == NetType.SIGNAL
def _graph(components, nets):
net_objs = {}
for name, (ntype, conns) in nets.items():
net_objs[name] = Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
return DesignGraph(components=components, nets=net_objs)
def _ic(ref, pins, mpn="UTEST"):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.IC, mpn=mpn, pins=pins,
)
def _cmap_vdd():
return {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
)
}
def test_missing_decoupling_is_warning():
g = _graph(
{"U1": _ic("U1", {"1": "3V3", "2": "GND"})},
{
"3V3": (NetType.POWER, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
},
)
findings = check_supply_decoupling(g, _cmap_vdd())
assert len(findings) == 1
assert findings[0].status == "WARNING"
assert findings[0].source == "supply_decoupling_check"
assert "3V3" in findings[0].finding
def test_cap_to_gnd_clears_decoupling():
cap = Component(
reference="C1", value="100n", footprint="",
component_type=ComponentType.CAPACITOR, mpn="C1",
pins={"1": "3V3", "2": "GND"},
)
g = _graph(
{"U1": _ic("U1", {"1": "3V3", "2": "GND"}), "C1": cap},
{
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
assert check_supply_decoupling(g, _cmap_vdd()) == []
def test_i2c_missing_pullup():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=8, name="SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"})},
{"I2C_SDA": (NetType.SIGNAL, [("U1", "8")])},
)
findings = check_i2c_pullups(g, cons)
assert len(findings) == 1
assert findings[0].source == "i2c_pullup_check"
assert findings[0].rule_id == "PE-I2C-001"
assert findings[0].net == "I2C_SDA"
def test_i2c_pullup_present():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=8, name="SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
r = Component(
reference="R1", value="4.7k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "I2C_SDA", "2": "3V3"},
)
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3": (NetType.POWER, [("R1", "2")]),
},
)
assert check_i2c_pullups(g, cons) == []
def test_i2c_pullup_to_3v3_digital_typed_as_signal():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=8, name="SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
r = Component(
reference="R1", value="4.7k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "I2C_SDA", "2": "3V3_DIGITAL"},
)
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3_DIGITAL": (NetType.SIGNAL, [("R1", "2")]),
},
)
assert check_i2c_pullups(g, cons) == []
def test_spi_pin_alias_sda_is_not_i2c():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=38, name="MISO/SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
g = _graph(
{"U1": _ic("U1", {"38": "SPI_MISO"})},
{"SPI_MISO": (NetType.SIGNAL, [("U1", "38")])},
)
assert check_i2c_pullups(g, cons) == []
def test_reset_no_finding_when_gpio_drives():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=3, name="nRESET")],
absolute_maximum_ratings=[], rules=[],
)
}
u2 = _ic("U2", {"1": "MCU_RST"}, mpn="MCU2")
g = _graph(
{"U1": _ic("U1", {"3": "MCU_RST"}), "U2": u2},
{"MCU_RST": (NetType.SIGNAL, [("U1", "3"), ("U2", "1")])},
)
assert check_reset_pullups(g, cons) == []
def test_reset_floating_is_warning():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=3, name="nRESET")],
absolute_maximum_ratings=[], rules=[],
)
}
g = _graph(
{"U1": _ic("U1", {"3": "NRST_NET"})},
{"NRST_NET": (NetType.SIGNAL, [("U1", "3")])},
)
findings = check_reset_pullups(g, cons)
assert len(findings) == 1
assert findings[0].source == "reset_pullup_check"
assert findings[0].status == "WARNING"
def test_enable_strapped_to_rail_is_not_decoupling():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[
Pin(number=1, name="EN"),
Pin(number=2, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
)
}
g = _graph(
{"U1": _ic("U1", {"1": "3V3", "2": "GND"})},
{
"3V3": (NetType.POWER, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
},
)
assert check_supply_decoupling(g, cons) == []
def test_i2c_from_slash_alias_in_pin_name():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=12, name="GPIO12/I2C1_SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
g = _graph(
{"U1": _ic("U1", {"12": "NET-U1-12"})},
{"NET-U1-12": (NetType.SIGNAL, [("U1", "12")])},
)
findings = check_i2c_pullups(g, cons)
assert len(findings) == 1
assert findings[0].source == "i2c_pullup_check"
def test_nc_supply_net_is_skipped():
g = _graph(
{"U1": _ic("U1", {"1": "NC"})},
{"NC": (NetType.POWER, [("U1", "1")])},
)
assert check_supply_decoupling(g, _cmap_vdd()) == []
def test_fb_and_rn_prefixes():
from backend.periscopex.graph import _classify_component
from backend.periscopex.models import ComponentType
assert _classify_component("FB1", "") == ComponentType.INDUCTOR
assert _classify_component("RN4", "") == ComponentType.RESISTOR
assert _classify_component("F1", "") == ComponentType.FUSE
def _res(ref, pins, value="4.7k", ohms=None):
specs = None
if ohms is not None:
from backend.periscopex.models import ResistorSpecs
specs = ResistorSpecs(value_ohms=ohms, value_formatted=f"{ohms}")
return Component(
reference=ref, value=value, footprint="",
component_type=ComponentType.RESISTOR, mpn=ref, pins=pins, specs=specs,
)
def _cap(ref, pins, value="100n", farads=None):
specs = None
if farads is not None:
from backend.periscopex.models import CapacitorSpecs
specs = CapacitorSpecs(value_farads=farads, value_formatted=value)
return Component(
reference=ref, value=value, footprint="",
component_type=ComponentType.CAPACITOR, mpn=ref, pins=pins, specs=specs,
)
def _cmap_i2c():
return {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=8, name="SDA")],
absolute_maximum_ratings=[], rules=[],
)
}
def test_i2c_4k7_pullup_is_in_nxp_wide_band():
r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, value="4.7k")
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3": (NetType.POWER, [("R1", "2")]),
},
)
assert check_i2c_pullups(g, _cmap_i2c()) == []
def test_i2c_100ohm_pullup_is_too_stiff():
r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, ohms=100)
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3": (NetType.POWER, [("R1", "2")]),
},
)
findings = check_i2c_pullups(g, _cmap_i2c())
assert len(findings) == 1
assert findings[0].rule_id == "PE-I2C-002"
assert findings[0].status == "WARNING"
def test_i2c_100k_pullup_is_too_weak():
r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, ohms=100_000)
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3": (NetType.POWER, [("R1", "2")]),
},
)
findings = check_i2c_pullups(g, _cmap_i2c())
assert [f.rule_id for f in findings] == ["PE-I2C-002"]
def test_i2c_pullup_without_value_is_not_sized():
r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, value="")
g = _graph(
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
{
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
"3V3": (NetType.POWER, [("R1", "2")]),
},
)
assert check_i2c_pullups(g, _cmap_i2c()) == []
def test_nrst_pulldown_is_warning():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=4, name="NRST")],
absolute_maximum_ratings=[], rules=[],
)
}
r = _res("R1", {"1": "/NRST", "2": "GND"}, value="10k")
g = _graph(
{"U1": _ic("U1", {"4": "/NRST"}), "R1": r},
{
"/NRST": (NetType.SIGNAL, [("U1", "4"), ("R1", "1")]),
"GND": (NetType.GROUND, [("R1", "2")]),
},
)
findings = check_reset_pullups(g, cons)
assert any(f.rule_id == "PE-RST-002" for f in findings)
def test_nrst_pullup_is_not_pulldown():
cons = {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=4, name="NRST")],
absolute_maximum_ratings=[], rules=[],
)
}
r = _res("R8", {"1": "+3V3", "2": "/NRST"}, value="5k1")
g = _graph(
{"U1": _ic("U1", {"4": "/NRST"}), "R8": r},
{
"/NRST": (NetType.SIGNAL, [("U1", "4"), ("R8", "2")]),
"+3V3": (NetType.POWER, [("R8", "1")]),
},
)
assert check_reset_pullups(g, cons) == []
def test_ldo_vout_needs_cout():
cons = {
"LDOX": ComponentConstraints(
mpn="LDOX",
pintable=[
Pin(number=1, name="VIN"),
Pin(number=2, name="VOUT"),
Pin(number=3, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
)
}
cin = _cap("C1", {"1": "VIN", "2": "GND"}, value="1u")
g = _graph(
{
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="LDOX",
pins={"1": "VIN", "2": "VOUT", "3": "GND"},
),
"C1": cin,
},
{
"VIN": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
"VOUT": (NetType.POWER, [("U1", "2")]),
"GND": (NetType.GROUND, [("U1", "3"), ("C1", "2")]),
},
)
findings = check_supply_decoupling(g, cons)
assert any(f.net == "VOUT" and f.rule_id == "PE-DEC-001" for f in findings)
assert not any(f.net == "VIN" for f in findings)
def test_ldo_vout_100n_only_is_value_warning():
cons = {
"LDOX": ComponentConstraints(
mpn="LDOX",
pintable=[Pin(number=2, name="VOUT")],
absolute_maximum_ratings=[], rules=[],
)
}
cout = _cap("C2", {"1": "VOUT", "2": "GND"}, farads=100e-9)
g = _graph(
{
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="LDOX",
pins={"2": "VOUT"},
),
"C2": cout,
},
{
"VOUT": (NetType.POWER, [("U1", "2"), ("C2", "1")]),
"GND": (NetType.GROUND, [("C2", "2")]),
},
)
findings = check_supply_decoupling(g, cons)
assert len(findings) == 1
assert findings[0].rule_id == "PE-DEC-002"
assert findings[0].status == "WARNING"
def test_vdd_100n_is_not_a_value_warning():
cap = _cap("C1", {"1": "3V3", "2": "GND"}, farads=100e-9)
g = _graph(
{"U1": _ic("U1", {"1": "3V3", "2": "GND"}), "C1": cap},
{
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
assert check_supply_decoupling(g, _cmap_vdd()) == []
+269
View File
@@ -0,0 +1,269 @@
"""Pin-mux feasibility check — net-asserted peripheral function vs. the pin's
silicon alternate-function table.
Locks in:
1. A net asserting a function the pin can't be muxed to (UART5_TX on an RX-only
pin) is a hard ERROR.
2. A correct assignment produces nothing.
3. DIRECTION is never flagged: an inter-device same-peripheral link (crossover /
transceiver) is skipped, not flagged.
4. Empty functions / opaque nets are skipped.
5. Deterministic findings carry source="pin_mux_check" and never source_page.
"""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
Net,
NetType,
Pin,
PinConnection,
ValidationReport,
)
from backend.periscopex.pin_function_tokens import normalize_functions, parse_net_token
from backend.periscopex.pin_mux_check import check_pin_mux_feasibility
def _constraints(mpn, pintable):
return ComponentConstraints(mpn=mpn, pintable=pintable,
absolute_maximum_ratings=[], rules=[])
def _ic(ref, mpn, pins):
return Component(reference=ref, value="", footprint="",
component_type=ComponentType.IC, mpn=mpn, pins=pins)
def _graph(components, nets):
"""nets: {net_name: [(ref, pin_num), ...]}"""
net_objs = {
name: Net(name=name, net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns])
for name, conns in nets.items()
}
return DesignGraph(components=components, nets=net_objs)
# STM32-style: PD2 (pin 54) does UART5_RX only; PC12 (pin 53) does UART5_TX only.
_PD2 = Pin(number=54, name="PD2", functions=["TIM3_ETR", "UART5_RX", "EVENTOUT"])
_PC12 = Pin(number=53, name="PC12", functions=["SPI3_MOSI/I2S3_SDO", "UART5_TX"])
def test_real_defect_uart5_swapped_is_error():
# Net labels assert TX on the RX-only pin and RX on the TX-only pin.
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX", "53": "MCU-UART5-RX"})
g = _graph({"U3": u3},
{"MCU-UART5-TX": [("U3", 54)], "MCU-UART5-RX": [("U3", 53)]})
cmap = {"MCUX": _constraints("MCUX", [_PD2, _PC12])}
findings = check_pin_mux_feasibility(g, cmap)
assert len(findings) == 2
assert all(f.status == "ERROR" for f in findings)
assert all(f.source == "pin_mux_check" for f in findings)
assert all(f.source_page is None for f in findings)
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 == "PE-MUX-001"
assert tx.net == "MCU-UART5-TX"
assert tx.pins == ["U3.54"]
def test_correct_assignment_no_finding():
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-RX", "53": "MCU-UART5-TX"})
g = _graph({"U3": u3},
{"MCU-UART5-RX": [("U3", 54)], "MCU-UART5-TX": [("U3", 53)]})
cmap = {"MCUX": _constraints("MCUX", [_PD2, _PC12])}
assert check_pin_mux_feasibility(g, cmap) == []
def test_inter_device_same_peripheral_link_is_skipped():
# A correct crossover: the net named from U3's TX perspective also lands on a
# peer IC pin that exposes UART5. Direction is the reviewer's call -> skip.
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
peer = _ic("U7", "PEER", {"5": "MCU-UART5-TX"})
g = _graph({"U3": u3, "U7": peer},
{"MCU-UART5-TX": [("U3", 54), ("U7", 5)]})
cmap = {
"MCUX": _constraints("MCUX", [_PD2]),
"PEER": _constraints("PEER", [Pin(number=5, name="RXD", functions=["UART5_TX"])]),
}
assert check_pin_mux_feasibility(g, cmap) == []
def test_transceiver_peer_without_peripheral_still_fires():
# Peer pin is a transceiver "DI" with no UART peripheral -> gate does NOT
# apply; the MCU pin is still genuinely infeasible -> ERROR.
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
xcvr = _ic("U9", "XCVR", {"1": "MCU-UART5-TX"})
g = _graph({"U3": u3, "U9": xcvr},
{"MCU-UART5-TX": [("U3", 54), ("U9", 1)]})
cmap = {
"MCUX": _constraints("MCUX", [_PD2]),
"XCVR": _constraints("XCVR", [Pin(number=1, name="DI", functions=["DI"])]),
}
findings = check_pin_mux_feasibility(g, cmap)
assert len(findings) == 1 and findings[0].status == "ERROR"
def test_empty_functions_skipped():
u3 = _ic("U3", "MCUX", {"54": "MCU-UART5-TX"})
g = _graph({"U3": u3}, {"MCU-UART5-TX": [("U3", 54)]})
cmap = {"MCUX": _constraints("MCUX", [Pin(number=54, name="PD2", functions=None)])}
assert check_pin_mux_feasibility(g, cmap) == []
def test_pin_exposes_peripheral_but_not_signal_no_complement():
# Net asserts I2C1_SDA on a pin that exposes I2C1 only as SCL -> infeasible.
u3 = _ic("U3", "MCUX", {"20": "I2C1-SDA-3V3"})
g = _graph({"U3": u3}, {"I2C1-SDA-3V3": [("U3", 20)]})
cmap = {"MCUX": _constraints("MCUX", [Pin(number=20, name="PB8", functions=["I2C1_SCL"])])}
findings = check_pin_mux_feasibility(g, cmap)
assert len(findings) == 1 and findings[0].status == "ERROR"
def test_opaque_net_not_flagged():
u3 = _ic("U3", "MCUX", {"54": "NetC7_1"})
g = _graph({"U3": u3}, {"NetC7_1": [("U3", 54)]})
cmap = {"MCUX": _constraints("MCUX", [_PD2])}
assert check_pin_mux_feasibility(g, cmap) == []
def test_token_parser_and_normalizer():
assert parse_net_token("MCU-UART5-TX") == ("UART5", "TX")
assert parse_net_token("I2C1-SDA-3V3") == ("I2C1", "SDA")
assert parse_net_token("/UART0.TX") == ("UART0", "TX")
assert parse_net_token("SPI2-CS") == ("SPI2", "NSS") # CS canonicalises to NSS
assert parse_net_token("NetC7_1") is None
assert parse_net_token("+5V") is None
assert ("UART5", "RX") in normalize_functions(["TIM3_ETR", "UART5_RX"])
assert normalize_functions(["SPI3_MOSI/I2S3_SDO"]) >= {("SPI3", "MOSI")}
# TI MSPM0-style pintable: modern controller/peripheral SPI nomenclature.
# PB17 (pin 36) exposes SPI0 as PICO (== MOSI); PB19 (pin 38) as POCI (== MISO).
_PB17 = Pin(number=36, name="PB17", functions=["UART2_TX", "SPI0_PICO", "SPI1_CS1"])
_PB19 = Pin(number=38, name="PB19", functions=["SPI0_POCI", "UART0_CTS"])
def test_spi_legacy_net_names_match_modern_pin_functions():
# Regression for the U3-001/U3-002 false positives: net labels use legacy
# MOSI/MISO, the datasheet uses PICO/POCI — the same physical lines. No
# finding: PICO≡MOSI, POCI≡MISO.
u3 = _ic("U3", "MSPM0G3507SPTR", {"36": "/SPI0.MOSI", "38": "/SPI0.MISO"})
g = _graph({"U3": u3},
{"/SPI0.MOSI": [("U3", 36)], "/SPI0.MISO": [("U3", 38)]})
cmap = {"MSPM0G3507SPTR": _constraints("MSPM0G3507SPTR", [_PB17, _PB19])}
assert check_pin_mux_feasibility(g, cmap) == []
def test_spi_controller_peripheral_names_are_synonyms():
assert parse_net_token("/SPI0.MOSI") == ("SPI0", "MOSI")
assert parse_net_token("/SPI0.PICO") == ("SPI0", "MOSI")
assert parse_net_token("SPI0-COPI") == ("SPI0", "MOSI")
assert parse_net_token("/SPI0.MISO") == ("SPI0", "MISO")
assert parse_net_token("/SPI0.POCI") == ("SPI0", "MISO")
assert parse_net_token("SPI0-CIPO") == ("SPI0", "MISO")
# Datasheet function strings collapse to the same canonical tokens.
assert normalize_functions(["SPI0_PICO"]) == {("SPI0", "MOSI")}
assert normalize_functions(["SPI0_POCI"]) == {("SPI0", "MISO")}
# Indexed chip-select variants canonicalise to NSS.
assert normalize_functions(["SPI0_CS0", "SPI1_CS3"]) == {
("SPI0", "NSS"), ("SPI1", "NSS")}
assert normalize_functions(["SPI0_STE0"]) == {("SPI0", "NSS")}
def test_simple_project_uart0_nets_are_feasible_on_mspm0_pins():
from pathlib import Path
from backend.periscopex.models import DesignGraph
graph = DesignGraph.model_validate_json(
(SIMPLE_PROJECT / "design_graph.json").read_text()
)
cmap = {
"MSPM0G3507SPTR": _constraints(
"MSPM0G3507SPTR",
[
Pin(number=1, name="PA11", functions=["UART0_TX", "SPI1_CS1"]),
Pin(number=2, name="PA12", functions=["UART0_RX", "SPI1_CS0"]),
],
)
}
findings = check_pin_mux_feasibility(graph, cmap)
uart = [f for f in findings if f.net and "UART0" in f.net]
assert uart == []
def test_simple_project_uart0_swapped_on_mspm0_is_error():
from pathlib import Path
from backend.periscopex.models import DesignGraph
graph = DesignGraph.model_validate_json(
(SIMPLE_PROJECT / "design_graph.json").read_text()
)
cmap = {
"MSPM0G3507SPTR": _constraints(
"MSPM0G3507SPTR",
[
Pin(number=1, name="PA11", functions=["UART0_RX"]),
Pin(number=2, name="PA12", functions=["UART0_TX"]),
],
)
}
findings = check_pin_mux_feasibility(graph, cmap)
nets = {f.net for f in findings}
assert "/UART0.TX" in nets
assert "/UART0.RX" in nets
assert all(f.status == "ERROR" for f in findings if f.net and "UART0" in f.net)
def test_spi_genuine_infeasibility_still_fires_with_modern_names():
# Net asserts SPI0_MOSI on a pin that exposes SPI0 only as POCI (==MISO) —
# genuinely infeasible even after synonym collapse -> ERROR.
u3 = _ic("U3", "MSPM0G3507SPTR", {"38": "/SPI0.MOSI"})
g = _graph({"U3": u3}, {"/SPI0.MOSI": [("U3", 38)]})
cmap = {"MSPM0G3507SPTR": _constraints("MSPM0G3507SPTR", [_PB19])}
findings = check_pin_mux_feasibility(g, cmap)
assert len(findings) == 1 and findings[0].status == "ERROR"
# POCI==MISO is the complement of MOSI -> phrased as a likely swap.
assert "swapped" in findings[0].why
def test_finding_prints_full_raw_capability_list_and_intent_caveat():
# Net asserts I2C1_SDA on a pin that exposes I2C1 only as SCL -> infeasible.
# The finding's `why` must (a) print the pin's full raw alternate-function
# list verbatim, and (b) state the intent was inferred from the net name.
pin = Pin(number=20, name="PB8", functions=["I2C1_SCL", "TIMA0_C1", "UART1_RX"])
u3 = _ic("U3", "MCUX", {"20": "I2C1-SDA-3V3"})
g = _graph({"U3": u3}, {"I2C1-SDA-3V3": [("U3", 20)]})
cmap = {"MCUX": _constraints("MCUX", [pin])}
f = check_pin_mux_feasibility(g, cmap)[0]
# (a) every raw datasheet function string appears verbatim in `why`.
for fn in ("I2C1_SCL", "TIMA0_C1", "UART1_RX"):
assert fn in f.why
# (b) the inferred-from-net-name caveat is present.
assert "inferred from the net name" in f.why
def test_legacy_report_without_source_validates():
# Backward-compat: a report.json from before these fields existed.
legacy = {
"finding_id": "U1-001", "designator": "U1", "mpn": "X",
"finding": "f", "why": "w", "source_page": 3, "status": "WARNING",
}
f = Finding.model_validate(legacy)
assert f.source is None
rep = ValidationReport.model_validate({
"project": "p", "timestamp": "t", "findings": [legacy],
"summary": {"total": 1}, "coverage": {}, "review_errors": {},
})
assert rep.not_reviewed == []
+124
View File
@@ -0,0 +1,124 @@
"""Regulator Iout margin and series-R IR drop — no invented IQ or traces."""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
)
from backend.periscopex.power_margin_check import check_power_margin
def _graph(components, nets):
net_objs = {}
for name, (ntype, volt, conns) in nets.items():
net_objs[name] = Net(
name=name, net_type=ntype, voltage=volt,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
return DesignGraph(components=components, nets=net_objs)
def _cons():
return {
"LDOX": ComponentConstraints(
mpn="LDOX", component_subtype="ic.power.ldo",
pintable=[
Pin(number=1, name="VIN"),
Pin(number=2, name="VOUT"),
Pin(number=3, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
)
}
def _ldo(values):
return Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, component_subtype="ic.power.ldo",
mpn="LDOX",
pins={"1": "VIN", "2": "VOUT", "3": "GND"},
specs=SimpleComponentSpecs(specs_type="ic", component_subtype="ic.power.ldo", values=values),
)
def _mcu(iq=None):
values = {} if iq is None else {"iq_a": iq}
return Component(
reference="U2", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "VOUT", "2": "GND"},
specs=SimpleComponentSpecs(specs_type="ic", values=values) if values else None,
)
def test_load_over_iout_max_is_ps_pwr_001():
g = _graph(
{
"U1": _ldo({"i_load_a": 0.4, "iout_max_a": 0.5}),
"U2": _mcu(0.2),
},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2"), ("U2", "1")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3"), ("U2", "2")]),
},
)
findings = check_power_margin(g, _cons())
assert any(f.rule_id == "PE-PWR-001" and f.designator == "U1" for f in findings)
def test_missing_iq_is_not_guessed_into_margin_fail():
g = _graph(
{
"U1": _ldo({"iout_max_a": 0.1}),
"U2": _mcu(None),
},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2"), ("U2", "1")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3"), ("U2", "2")]),
},
)
assert check_power_margin(g, _cons()) == []
def test_series_r_ir_drop_uses_i_load_not_trace():
r = Component(
reference="R1", value="1", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "USB", "2": "VIN"},
specs=ResistorSpecs(value_ohms=1.0, value_formatted="1"),
)
g = _graph(
{"U1": _ldo({"i_load_a": 0.5, "iout_max_a": 1.0}), "R1": r},
{
"USB": (NetType.POWER, 5.0, [("R1", "1")]),
"VIN": (NetType.POWER, 5.0, [("R1", "2"), ("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
},
)
findings = check_power_margin(g, _cons())
assert any(f.designator == "R1" and f.rule_id == "PE-PWR-001" for f in findings)
def test_no_series_r_does_not_invent_trace_drop():
g = _graph(
{"U1": _ldo({"i_load_a": 0.5, "iout_max_a": 1.0})},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
},
)
assert check_power_margin(g, _cons()) == []
@@ -0,0 +1,59 @@
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
)
from backend.periscopex.review_fingerprint import (
graph_ic_fingerprints,
skip_unchanged_ics,
)
def _g():
u1 = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="IC1",
pins={"1": "3V3", "2": "SDA"},
)
r1 = Component(
reference="R1", value="4k7", footprint="",
component_type=ComponentType.RESISTOR, mpn="R",
pins={"1": "SDA", "2": "3V3"},
)
return DesignGraph(
components={"U1": u1, "R1": r1},
nets={
"3V3": Net(name="3V3", net_type=NetType.POWER, pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="R1", pin_number="2"),
]),
"SDA": Net(name="SDA", net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="R1", pin_number="1"),
]),
},
)
def test_fingerprint_changes_when_neighbor_added():
g = _g()
fp1 = graph_ic_fingerprints(g)["U1"]
c2 = Component(
reference="C1", value="100n", footprint="",
component_type=ComponentType.CAPACITOR, mpn="C",
pins={"1": "3V3", "2": "GND"},
)
g.components["C1"] = c2
g.nets["3V3"].pins.append(PinConnection(component_ref="C1", pin_number="1"))
fp2 = graph_ic_fingerprints(g)["U1"]
assert fp1 != fp2
def test_skip_unchanged_drops_changed_refs():
prev = {"U1": "aaa", "U2": "bbb"}
cur = {"U1": "aaa", "U2": "ccc"}
skip = skip_unchanged_ics({"U1", "U2"}, prev, cur)
assert skip == {"U1"}
+48
View File
@@ -0,0 +1,48 @@
"""submit_review parsing — ERROR without a datasheet quote is demoted."""
from backend.periscopex.review_parse import parse_submit_review as _parse_review
def test_error_without_quote_becomes_unverified_warning():
result = _parse_review(
{
"findings": [
{
"finding": "U14 unidirectional clamp clips audio",
"why": "IO-to-GND diode conducts at -0.8 V.",
"status": "ERROR",
"source_page": 3,
"source_quote": "",
"recommendation": "Use a bidirectional array.",
}
],
"checked_areas": ["ESD"],
},
"U14",
"TPD2E007DCKR",
)
assert len(result.findings) == 1
f = result.findings[0]
assert f.status == "WARNING"
assert f.why.startswith("Unverified: no verbatim datasheet quote.")
def test_error_with_quote_stays_error():
result = _parse_review(
{
"findings": [
{
"finding": "FB5 floating",
"why": "FB5 is NC; DCDC5 SW is loaded.",
"status": "ERROR",
"source_page": 12,
"source_quote": "Connect FB5 to the output sense node.",
}
],
"checked_areas": [],
},
"U16",
"AXP2101",
)
assert result.findings[0].status == "ERROR"
assert not result.findings[0].why.startswith("Unverified:")
+157
View File
@@ -0,0 +1,157 @@
"""Wave H — finding review state, ECO, release signature.
Favor: false_positive/accepted/wontfix with a reason persist; accepted
rows land in eco.json; signature hashes the findings payload.
Against: empty reason; unknown state; false_positive is not an ECO row;
unsigned report has no release block.
"""
from __future__ import annotations
import pytest
from backend.periscopex.models import Finding
from backend.periscopex.review_workflow import (
ReviewError,
apply_review_state,
build_eco,
eco_csv,
sign_report,
)
def _finding(**kwargs):
defaults = dict(
finding_id="U1-001",
designator="U1",
mpn="PART",
aspect="decoupling",
finding="missing cap",
why="no 100nF on VDD",
status="ERROR",
recommendation="add 100nF",
rule_id="PE-DEC-001",
)
defaults.update(kwargs)
return Finding(**defaults)
def test_false_positive_requires_reason():
with pytest.raises(ReviewError):
apply_review_state({}, "U1-001", state="false_positive", reason=" ", user_id="local")
def test_unknown_state_is_rejected():
with pytest.raises(ReviewError):
apply_review_state({}, "U1-001", state="fixed", reason="ok", user_id="local")
def test_accepted_with_reason_is_stored():
states = apply_review_state(
{}, "U1-001", state="accepted", reason="will spin ECO-12", user_id="local",
user_name="Michele",
)
assert states["U1-001"]["state"] == "accepted"
assert states["U1-001"]["reason"] == "will spin ECO-12"
assert states["U1-001"]["user_id"] == "local"
def test_eco_includes_accepted_not_false_positive():
findings = [
_finding(finding_id="U1-001"),
_finding(finding_id="U2-001", designator="U2", finding="noise"),
]
states = apply_review_state({}, "U1-001", state="accepted", reason="add cap", user_id="a")
states = apply_review_state(states, "U2-001", state="false_positive", reason="ok in app", user_id="a")
eco = build_eco(findings, states)
assert [row["finding_id"] for row in eco] == ["U1-001"]
assert eco[0]["rule_id"] == "PE-DEC-001"
assert eco[0]["ref"] == "U1"
assert "100nF" in eco[0]["after"]
csv = eco_csv(eco)
assert "U1-001" in csv
assert "U2-001" not in csv
def test_open_and_wontfix_are_not_eco_rows():
findings = [_finding()]
states = apply_review_state({}, "U1-001", state="wontfix", reason="wont ship", user_id="a")
assert build_eco(findings, states) == []
assert build_eco(findings, {}) == []
def test_signature_changes_when_findings_change():
a = sign_report({"findings": [{"finding_id": "U1-001"}]}, user_id="local")
b = sign_report({"findings": [{"finding_id": "U1-002"}]}, user_id="local")
assert a["user_id"] == "local"
assert a["sha256"] != b["sha256"]
assert a["timestamp"]
def _client(tmp_path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
return TestClient(app)
def _seed_report(client, findings):
meta = client.post("/api/projects", json={"name": "board"}).json()
pid = meta["id"]
storage = client.app.state.storage
prefix = f"users/local/projects/{pid}"
storage.write_json(f"{prefix}/report.json", {
"project": "board",
"timestamp": "2026-01-01T00:00:00+00:00",
"findings": [f.model_dump() for f in findings],
"summary": {"total": len(findings), "ERROR": 1, "WARNING": 0, "INFO": 0},
})
return pid
def test_api_review_without_reason_is_400(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
"state": "accepted", "reason": "",
})
assert res.status_code == 400
def test_api_review_and_eco(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/U1-001/review", json={
"state": "accepted", "reason": "add 100nF near U1.3",
})
assert res.status_code == 200
report = client.get(f"/api/report/{pid}").json()
assert report["review_states"]["U1-001"]["state"] == "accepted"
eco = client.get(f"/api/report/{pid}/eco.json").json()
assert eco["items"][0]["finding_id"] == "U1-001"
csv = client.get(f"/api/report/{pid}/eco.csv")
assert csv.status_code == 200
assert "U1-001" in csv.text
def test_api_unknown_finding_is_404(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.put(f"/api/report/{pid}/findings/NOPE/review", json={
"state": "accepted", "reason": "x",
})
assert res.status_code == 404
def test_api_sign_release(tmp_path):
client = _client(tmp_path)
pid = _seed_report(client, [_finding()])
res = client.post(f"/api/report/{pid}/sign")
assert res.status_code == 200
body = res.json()
assert len(body["sha256"]) == 64
report = client.get(f"/api/report/{pid}").json()
assert report["release"]["sha256"] == body["sha256"]
assert report["release"]["user_id"] == "local"
+91
View File
@@ -0,0 +1,91 @@
"""PG→EN sequencing only when power_sequence is in IC specs."""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
SimpleComponentSpecs,
)
from backend.periscopex.sequencing_check import check_power_sequencing
def _graph(components, nets):
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=components, nets=net_objs)
def _cons():
return {
"L1": ComponentConstraints(
mpn="L1", component_subtype="ic.power.ldo",
pintable=[
Pin(number=1, name="VIN"), Pin(number=2, name="VOUT"),
Pin(number=3, name="PG"), Pin(number=4, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
),
"L2": ComponentConstraints(
mpn="L2", component_subtype="ic.power.ldo",
pintable=[
Pin(number=1, name="VIN"), Pin(number=2, name="VOUT"),
Pin(number=3, name="EN"), Pin(number=4, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
),
}
def _ldo(ref, mpn, pins, values=None):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.IC, component_subtype="ic.power.ldo",
mpn=mpn, pins=pins,
specs=SimpleComponentSpecs(
specs_type="ic", component_subtype="ic.power.ldo", values=values or {},
),
)
def _dual(pg_net, en_net, sequence=True):
u1 = _ldo("U1", "L1", {"1": "5V", "2": "3V3", "3": pg_net, "4": "GND"})
vals = {"power_sequence": "pg_before_en"} if sequence else {}
u2 = _ldo("U2", "L2", {"1": "3V3", "2": "1V8", "3": en_net, "4": "GND"}, vals)
return _graph(
{"U1": u1, "U2": u2},
{
"5V": (NetType.POWER, [("U1", "1")]),
"3V3": (NetType.POWER, [("U1", "2"), ("U2", "1")]),
"1V8": (NetType.POWER, [("U2", "2")]),
pg_net: (NetType.SIGNAL, [("U1", "3")] + ([("U2", "3")] if pg_net == en_net else [])),
**({en_net: (NetType.SIGNAL, [("U2", "3")])} if pg_net != en_net else {}),
"GND": (NetType.GROUND, [("U1", "4"), ("U2", "4")]),
},
)
def test_pg_not_tied_to_en_is_warning():
findings = check_power_sequencing(_dual("PGOOD", "EN_1V8"), _cons())
assert len(findings) == 1
assert findings[0].rule_id == "PE-SEQ-001"
assert findings[0].status == "WARNING"
def test_pg_tied_to_en_is_silent():
assert check_power_sequencing(_dual("SEQ", "SEQ"), _cons()) == []
def test_no_power_sequence_spec_skips_even_if_pg_open():
assert check_power_sequencing(_dual("PGOOD", "EN_1V8", sequence=False), _cons()) == []
@@ -0,0 +1,234 @@
"""Concurrency of the gated review path in validate_design_async.
The pipeline drives validate_design_async with a ``before_ic`` credit gate,
which (since the parallelism change) runs up to ``settings.ic_concurrency``
IC reviews at once. These tests build a minimal synthetic graph (no real
datasheets, no network — review_ic_async is faked) and assert:
* in-flight reviews are bounded by the single ``ic_concurrency`` knob,
* each IC's API calls land in its *own* private ApiLogger (no cross-billing
between concurrent ICs — the bug the private-logger design prevents),
* a gate that trips mid-run stops *new* reviews (pause is honoured).
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from pypdf import PdfWriter
from backend.config import settings
from backend.periscopex.models import Component, ComponentType, DesignGraph, Finding
from backend.periscopex.utils import safe_mpn
from backend.periscopex.review_parse import ReviewResult
from backend.services import validation as val
from backend.services.api_logs import ApiLogger
from backend.services.storage import LocalStorageBackend
PREFIX = "users/local/projects/test"
# Distinct MPNs so each IC maps to its own datasheet PDF + private logger.
IC_MPNS = {f"U{i}": f"MPN-{i}" for i in range(1, 6)} # 5 ICs
def _blank_pdf(path: Path) -> None:
w = PdfWriter()
w.add_blank_page(width=200, height=200)
with path.open("wb") as fh:
w.write(fh)
@pytest.fixture
def workspace(tmp_path):
data = tmp_path / "data"
proj = data / PREFIX
proj.mkdir(parents=True)
# Minimal graph: one IC component per MPN (plus their datasheet PDFs).
graph = DesignGraph(
components={
ref: Component(
reference=ref,
value=mpn,
footprint="LQFP",
component_type=ComponentType.IC,
mpn=mpn,
)
for ref, mpn in IC_MPNS.items()
}
)
graph_path = proj / "design_graph.json"
graph_path.write_text(graph.model_dump_json())
extracted = proj / "extracted"
extracted.mkdir()
ds_dir = proj / "uploads" / "datasheets"
ds_dir.mkdir(parents=True)
for mpn in IC_MPNS.values():
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
return dict(
data=data,
graph=graph_path,
report=proj / "report.json",
extracted=extracted,
ds_dir=ds_dir,
storage=LocalStorageBackend(data),
)
def _finding(ref: str) -> Finding:
return Finding(designator=ref, finding=f"{ref} note", status="INFO")
def _make_fake_review(tracker: dict, *, log_entries):
"""Fake review_ic_async: logs ``log_entries(ref)`` calls to the *private*
logger it's handed, records peak concurrency, and yields so reviews truly
overlap."""
async def fake_review_ic_async(graph, cmap, ic_ref, pdf_path, *,
api_logger=None, **kw):
tracker["in_flight"] += 1
tracker["peak"] = max(tracker["peak"], tracker["in_flight"])
try:
# Real suspension point so the event loop can interleave reviews.
await asyncio.sleep(0.02)
# Log this IC's API calls into ITS OWN private logger. Each IC logs
# a distinct number of entries so cross-billing would be visible.
for k in range(log_entries(ic_ref)):
api_logger.log(
stage="validation", identifier=ic_ref, model="fake",
input_tokens=100, output_tokens=50, duration_ms=1,
stop_reason="end_turn", turns=1,
)
return ReviewResult(findings=[_finding(ic_ref)], checked_areas=["power"]), {}
finally:
tracker["in_flight"] -= 1
return fake_review_ic_async
@pytest.mark.asyncio
async def test_concurrency_bounded_by_knob_and_charging_isolated(workspace, monkeypatch):
# One unique entry-count per IC (U1->1, U2->2, ... U5->5).
entries_for = {ref: i + 1 for i, ref in enumerate(IC_MPNS)}
tracker = {"in_flight": 0, "peak": 0}
monkeypatch.setattr(
val, "review_ic_async",
_make_fake_review(tracker, log_entries=lambda ref: entries_for[ref]),
)
monkeypatch.setattr(settings, "ic_concurrency", 3)
shared = ApiLogger()
charged: dict[str, int] = {}
async def before_ic(ref):
return True
async def on_ic_done(ref, result, private):
# Mirror _charge_private_logger: the private logger must hold EXACTLY
# this IC's entries — never another concurrent IC's.
assert all(e["identifier"] == ref for e in private.entries), \
f"{ref}'s private logger leaked another IC's entries: {private.entries}"
charged[ref] = len(private.entries)
shared.entries.extend(private.entries)
await val.validate_design_async(
str(workspace["graph"]),
str(workspace["report"]),
str(workspace["extracted"]),
pdf_dir=str(workspace["ds_dir"]),
api_logger=shared,
storage=workspace["storage"],
before_ic=before_ic,
on_ic_done=on_ic_done,
)
# Knob bounds parallelism: 5 ICs, knob=3 -> peak exactly 3.
assert tracker["peak"] == 3, f"expected peak 3, got {tracker['peak']}"
# Per-IC charging isolated and exact: each IC charged its own entry count.
assert charged == entries_for
# Shared log accumulated every IC's calls once (1+2+3+4+5 = 15).
assert len(shared.entries) == sum(entries_for.values()) == 15
report = json.loads(workspace["report"].read_text())
review_n = sum(
1 for f in report["findings"] if f.get("source") in (None, "review")
)
assert review_n == len(IC_MPNS) # all 5 reviewed
if_ids = {
f.get("rule_id") for f in report["findings"]
if (f.get("rule_id") or "").startswith(("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR"))
}
assert not if_ids # five ICs, no USB-C/RJ45/DDR — do not invent those certifiers
@pytest.mark.asyncio
async def test_one_knob_scales_to_one_is_sequential(workspace, monkeypatch):
tracker = {"in_flight": 0, "peak": 0}
monkeypatch.setattr(
val, "review_ic_async",
_make_fake_review(tracker, log_entries=lambda ref: 1),
)
monkeypatch.setattr(settings, "ic_concurrency", 1)
async def before_ic(ref):
return True
await val.validate_design_async(
str(workspace["graph"]),
str(workspace["report"]),
str(workspace["extracted"]),
pdf_dir=str(workspace["ds_dir"]),
api_logger=ApiLogger(),
storage=workspace["storage"],
before_ic=before_ic,
)
# ic_concurrency=1 -> never more than one review in flight (sequential).
assert tracker["peak"] == 1
@pytest.mark.asyncio
async def test_gate_trip_stops_new_reviews(workspace, monkeypatch):
tracker = {"in_flight": 0, "peak": 0}
monkeypatch.setattr(
val, "review_ic_async",
_make_fake_review(tracker, log_entries=lambda ref: 1),
)
monkeypatch.setattr(settings, "ic_concurrency", 2)
reviewed: list[str] = []
gate_calls = {"n": 0}
LIMIT = 2 # allow exactly the first 2 gate checks, then "out of credits"
async def before_ic(ref):
# Synchronous (no await) -> atomic counter, so exactly LIMIT pass.
gate_calls["n"] += 1
return gate_calls["n"] <= LIMIT
async def on_ic_done(ref, result, private):
reviewed.append(ref)
report_obj = await val.validate_design_async(
str(workspace["graph"]),
str(workspace["report"]),
str(workspace["extracted"]),
pdf_dir=str(workspace["ds_dir"]),
api_logger=ApiLogger(),
storage=workspace["storage"],
before_ic=before_ic,
on_ic_done=on_ic_done,
)
# Exactly LIMIT ICs got past the gate and were reviewed; the rest were
# stopped without starting work.
assert len(reviewed) == LIMIT, f"reviewed={reviewed}"
# Report is marked partial because a gate tripped.
report = json.loads(workspace["report"].read_text())
assert report.get("partial") is True
review_n = sum(
1 for f in report["findings"] if f.get("source") in (None, "review")
)
assert review_n == LIMIT
@@ -0,0 +1,83 @@
"""Integration: deterministic findings are seeded into report.json and skipped
ICs surface under not_reviewed — driven through validate_design_async with no
datasheet PDFs (so the LLM review loop is empty and no API call is made)."""
from __future__ import annotations
import json
import pytest
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
)
from backend.services import validation as val
@pytest.mark.asyncio
async def test_deterministic_findings_seeded_and_not_reviewed(tmp_path):
proj = tmp_path / "proj"
extracted = proj / "extracted"
extracted.mkdir(parents=True)
pdf_dir = proj / "pdfs"
pdf_dir.mkdir()
# U3: a UART5_TX net on the RX-only pin (pin-mux ERROR). U6: no MPN -> no
# datasheet -> not_reviewed. No PDFs anywhere -> review loop is empty.
graph = DesignGraph(
components={
"U3": Component(reference="U3", value="", footprint="",
component_type=ComponentType.IC, mpn="MCUX",
pins={"54": "MCU-UART5-TX"}),
"U6": Component(reference="U6", value="", footprint="",
component_type=ComponentType.IC, mpn=None,
pins={"1": "I2C1-SCL-3V3"}),
},
nets={
"MCU-UART5-TX": Net(name="MCU-UART5-TX", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U3", pin_number="54")]),
"I2C1-SCL-3V3": Net(name="I2C1-SCL-3V3", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U6", pin_number="1")]),
},
)
graph_path = proj / "design_graph.json"
graph_path.write_text(graph.model_dump_json())
cons = ComponentConstraints(
mpn="MCUX",
pintable=[Pin(number=54, name="PD2", functions=["UART5_RX"])],
absolute_maximum_ratings=[], rules=[],
)
(extracted / "MCUX.json").write_text(cons.model_dump_json())
report_path = proj / "report.json"
await val.validate_design_async(
graph_path=str(graph_path),
output_path=str(report_path),
datasheets_dir=str(extracted),
pdf_dir=str(pdf_dir),
storage=None,
)
data = json.loads(report_path.read_text())
det = [f for f in data["findings"] if f.get("source") == "pin_mux_check"]
assert len(det) == 1
assert det[0]["status"] == "ERROR"
assert det[0]["designator"] == "U3"
assert det[0]["finding_id"] # assign_finding_ids ran over it
assert det[0]["source_page"] is None
assert data["summary"]["ERROR"] >= 1
assert {x["designator"] for x in data["not_reviewed"]} == {"U3", "U6"}
by_ref = {x["designator"]: x["reason"] for x in data["not_reviewed"]}
assert by_ref["U3"] == "no datasheet PDF"
assert by_ref["U6"].startswith("INSUFFICIENT_EVIDENCE")
assert "not in BOM" in by_ref["U6"]
@@ -0,0 +1,177 @@
"""Recovery path: when a turn produces no tool calls (model wrote findings
as a JSON code block in prose instead of calling submit_review), the loop
must NOT drop the work — it should nudge and force submit_review next turn.
This regression was introduced by a system-prompt change that made Gemini
default to text output for findings. The fix is in the review loop itself
so it survives future prompt regressions.
"""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
import json
from pathlib import Path
import pytest
from pypdf import PdfWriter
from backend.periscopex.utils import safe_mpn
from backend.services import review_session as review_session
from backend.services import validation as val
from backend.services.llm.types import Completion, ToolCall, Usage
from backend.services.storage import LocalStorageBackend
GRAPH = SIMPLE_PROJECT / "design_graph.json"
IC_MPNS = {
"U1": "SPX3819M5-L-3-3/TR",
"U2": "CH340E",
"U3": "MSPM0G3507SPTR",
}
PREFIX = "users/local/projects/test"
def _blank_pdf(path: Path) -> None:
w = PdfWriter()
w.add_blank_page(width=200, height=200)
with path.open("wb") as fh:
w.write(fh)
def _usage():
return Usage(input_tokens=10, output_tokens=5,
cache_creation_tokens=0, cache_read_tokens=0)
@pytest.fixture
def workspace(tmp_path):
data = tmp_path / "data"
(data / PREFIX).mkdir(parents=True)
graph_path = data / PREFIX / "design_graph.json"
graph_path.write_text(GRAPH.read_text())
report_path = data / PREFIX / "report.json"
extracted = data / PREFIX / "extracted"
extracted.mkdir()
ds_dir = data / PREFIX / "uploads" / "datasheets"
ds_dir.mkdir(parents=True)
for mpn in IC_MPNS.values():
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
storage = LocalStorageBackend(data)
return dict(data=data, graph=graph_path, report=report_path,
extracted=extracted, ds_dir=ds_dir, storage=storage)
def _recovery_script(ic_ref, n):
"""Turn 0: text only, no tool calls (the failure mode).
Turn 1: submit_review under forced tool_choice (the recovery)."""
if n == 0:
return Completion(
text="I'll write up findings as JSON below: [...]",
tool_calls=[], # the bug: no tool calls
usage=_usage(),
stop_reason="end_turn",
raw_assistant_blocks=[],
)
# Turn 1 — forced submit_review under the recovery
return Completion(
text="",
tool_calls=[ToolCall(id="t2", name="submit_review", input={
"findings": [{
"finding": "Recovered finding.",
"why": "Recovered from a no-tool-call turn.",
"status": "INFO",
"source_page": 1,
}],
"checked_areas": ["recovery"],
})],
usage=_usage(),
stop_reason="tool_use",
raw_assistant_blocks=[],
)
@pytest.mark.asyncio
async def test_no_tool_calls_triggers_forced_submit_next_turn(workspace, monkeypatch):
"""A turn with zero tool calls should not drop the review — the next
turn must be forced to submit_review and the resulting findings must
land in the report."""
seen_tool_choices: list = []
class _Session:
def __init__(self, ic_ref):
self._ic = ic_ref
self._n = 0
async def complete(self, messages, tools, tool_choice):
seen_tool_choices.append((self._ic, self._n, tool_choice))
c = _recovery_script(self._ic, self._n)
self._n += 1
return c
async def close(self):
pass
class _Provider:
name = "fake"
async def create_session(self, model, system, max_tokens, **_kwargs):
return _Session(_Provider._current_ic)
_current_ic = None
async def fake_cwf(stage, body):
return await body(_Provider(), "fake-model")
monkeypatch.setattr(val, "call_with_fallback", fake_cwf)
monkeypatch.setattr(review_session, "call_with_fallback", fake_cwf)
orig = val.review_ic_async
async def wrapped(graph, cmap, ic_ref, pdf_path, **kw):
_Provider._current_ic = ic_ref
return await orig(graph, cmap, ic_ref, pdf_path, **kw)
monkeypatch.setattr(val, "review_ic_async", wrapped)
async def before_ic(ref):
return True
await val.validate_design_async(
str(workspace["graph"]),
str(workspace["report"]),
str(workspace["extracted"]),
pdf_dir=str(workspace["ds_dir"]),
storage=workspace["storage"],
before_ic=before_ic,
project_prefix=PREFIX,
run_meta={"git_commit": "testsha"},
)
# All three ICs should have recovered: each had a no-tool-call turn 0,
# then submit_review under forced tool_choice on turn 1.
report = json.loads(workspace["report"].read_text())
review = [f for f in report["findings"] if not f.get("rule_id")]
assert len(review) == 3
assert sum(1 for f in review if f.get("status") == "INFO") == 3
# Verify the recovery actually forced submit_review on turn 1 for each IC.
by_ic_turn = {(ic, n): tc for ic, n, tc in seen_tool_choices}
for ic in ("U1", "U2", "U3"):
# Turn 0 should be auto (model free to use any tool)
assert by_ic_turn[(ic, 0)] == "auto", \
f"turn 0 for {ic} should be auto, got {by_ic_turn[(ic, 0)]!r}"
# Turn 1 should be forced submit_review (the recovery)
assert by_ic_turn[(ic, 1)] == {"name": "submit_review"}, \
f"turn 1 for {ic} should force submit_review, got {by_ic_turn[(ic, 1)]!r}"
# Each trace should show the recovery: turn 0 has no tool calls, turn 1
# has submit_review.
traces_dir = workspace["data"] / PREFIX / "review_traces"
for ref in ("U1", "U2", "U3"):
t = json.loads((traces_dir / f"{safe_mpn(ref)}.json").read_text())
assert len(t["turns"]) == 2
assert t["turns"][0]["tool_calls"] == []
assert t["turns"][1]["tool_calls"][0]["name"] == "submit_review"
assert t["stop_reason"] == "submit_review"
@@ -0,0 +1,240 @@
"""Per-IC validation trace log — end-to-end on the async (gated) pipeline path.
Drives validate_design_async with a scripted fake LLM provider (no API key,
no network) against the real simple_project graph, asserting that each IC
leaves a review_traces/<safe_mpn>.json transcript with the expected schema,
and that trace failures never break the review/report.
"""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
import json
import re
from pathlib import Path
import pytest
from pypdf import PdfWriter
from backend.periscopex.utils import safe_mpn
from backend.services import review_session as review_session
from backend.services import validation as val
from backend.services.llm.types import Completion, ToolCall, Usage
from backend.services.storage import LocalStorageBackend
GRAPH = SIMPLE_PROJECT / "design_graph.json"
IC_MPNS = {
"U1": "SPX3819M5-L-3-3/TR",
"U2": "CH340E",
"U3": "MSPM0G3507SPTR",
}
PREFIX = "users/local/projects/test"
def _blank_pdf(path: Path) -> None:
w = PdfWriter()
w.add_blank_page(width=200, height=200)
with path.open("wb") as fh:
w.write(fh)
def _fake_provider(script):
"""Return a provider whose session.complete() yields scripted Completions.
`script(ic_ref, call_idx)` -> Completion. call_idx resets per session
(i.e. per IC review attempt).
"""
class _Session:
def __init__(self, ic_ref):
self._ic = ic_ref
self._n = 0
async def complete(self, messages, tools, tool_choice):
c = script(self._ic, self._n)
self._n += 1
return c
async def close(self):
pass
class _Provider:
name = "fake"
async def create_session(self, model, system, max_tokens, **_kwargs):
# ic_ref is recoverable from the build_component_context text in
# the first user message; simpler: stash via closure below.
# **_kwargs absorbs temperature= (and any future session knobs).
return _Session(_Provider._current_ic)
_current_ic = None
return _Provider
def _usage():
return Usage(input_tokens=10, output_tokens=5,
cache_creation_tokens=2, cache_read_tokens=1)
def _script(ic_ref, n):
if n == 0:
return Completion(
text="",
tool_calls=[ToolCall(id="t1", name="get_pintable",
input={"designator": ic_ref})],
usage=_usage(),
stop_reason="tool_use",
raw_assistant_blocks=[],
)
return Completion(
text="done",
tool_calls=[ToolCall(id="t2", name="submit_review", input={
"findings": [{
"finding": "VCAP not connected.",
"why": "Datasheet requires 1uF on VCAP.",
"status": "WARNING",
"source_page": 7,
}],
"checked_areas": ["power", "decoupling"],
})],
usage=_usage(),
stop_reason="tool_use",
raw_assistant_blocks=[],
)
@pytest.fixture
def workspace(tmp_path):
data = tmp_path / "data"
(data / PREFIX).mkdir(parents=True)
graph_path = data / PREFIX / "design_graph.json"
graph_path.write_text(GRAPH.read_text())
report_path = data / PREFIX / "report.json"
extracted = data / PREFIX / "extracted"
extracted.mkdir()
ds_dir = data / PREFIX / "uploads" / "datasheets"
ds_dir.mkdir(parents=True)
for mpn in IC_MPNS.values():
_blank_pdf(ds_dir / f"{safe_mpn(mpn)}.pdf")
storage = LocalStorageBackend(data)
return dict(data=data, graph=graph_path, report=report_path,
extracted=extracted, ds_dir=ds_dir, storage=storage)
async def _run(ws, monkeypatch, before_ic):
"""Patch the provider seam and run the gated path."""
prov_cls = _fake_provider(_script)
# review_ic_async calls call_with_fallback("validation", _run); short-circuit
# it to invoke the body directly with our fake provider, exercising the
# real loop + trace instrumentation.
async def fake_cwf(stage, body):
# build_component_context is called inside body; the fake session needs
# the ic_ref. Patch _select_review_pages to a no-op and recover ic_ref
# via the provider's _current_ic class attr set per call below.
return await body(prov_cls(), "fake-model")
monkeypatch.setattr(val, "call_with_fallback", fake_cwf)
monkeypatch.setattr(review_session, "call_with_fallback", fake_cwf)
# The fake session needs the current ic_ref; thread it through by wrapping
# review_ic_async to set the class attr before delegating.
orig = val.review_ic_async
async def wrapped(graph, cmap, ic_ref, pdf_path, **kw):
prov_cls._current_ic = ic_ref
return await orig(graph, cmap, ic_ref, pdf_path, **kw)
monkeypatch.setattr(val, "review_ic_async", wrapped)
return await val.validate_design_async(
str(ws["graph"]),
str(ws["report"]),
str(ws["extracted"]),
pdf_dir=str(ws["ds_dir"]),
storage=ws["storage"],
before_ic=before_ic,
project_prefix=PREFIX,
run_meta={"git_commit": "testsha"},
)
@pytest.mark.asyncio
async def test_trace_written_per_ic_with_schema(workspace, monkeypatch):
async def before_ic(ref):
return True
await _run(workspace, monkeypatch, before_ic)
traces_dir = workspace["data"] / PREFIX / "review_traces"
for ref, mpn in IC_MPNS.items():
# Keyed by safe_mpn(ic_ref) per the design — the designator, not MPN.
f = traces_dir / f"{safe_mpn(ref)}.json"
assert f.is_file(), f"missing trace for {ref}"
t = json.loads(f.read_text())
assert t["trace_version"] == 1
assert t["ic_ref"] == ref
assert t["mpn"] == mpn
assert t["provider"] == "fake"
assert t["git_commit"] == "testsha"
assert re.fullmatch(r"[0-9a-f]{32}", t["datasheet"]["md5"])
assert t["datasheet"]["safe_mpn"] == safe_mpn(mpn)
assert len(t["turns"]) == 2
tc0 = t["turns"][0]["tool_calls"][0]
assert tc0["name"] == "get_pintable"
assert tc0["input"] == {"designator": ref}
assert isinstance(tc0["output"], str) and tc0["output"]
assert t["turns"][0]["usage"]["input_tokens"] == 10
assert t["turns"][1]["tool_calls"][0]["name"] == "submit_review"
assert t["final_submission"]["checked_areas"] == ["power", "decoupling"]
assert t["stop_reason"] == "submit_review"
assert t["result"]["findings_count"] == 1
assert t["error"] is None
assert isinstance(t["duration_ms"], int)
report = json.loads(workspace["report"].read_text())
# 3 ICs x 1 LLM finding each (deterministic checks may add more)
review = [f for f in report["findings"] if not f.get("rule_id")]
assert len(review) == 3
@pytest.mark.asyncio
async def test_trace_write_failure_does_not_break_review(workspace, monkeypatch):
storage = workspace["storage"]
real_write = storage.write_json
def flaky(key, data):
if "review_traces" in key:
raise RuntimeError("simulated trace storage outage")
return real_write(key, data)
monkeypatch.setattr(storage, "write_json", flaky)
async def before_ic(ref):
return True
# Must not raise despite every trace write failing.
await _run(workspace, monkeypatch, before_ic)
report = json.loads(workspace["report"].read_text())
review = [f for f in report["findings"] if not f.get("rule_id")]
assert len(review) == 3
assert not (workspace["data"] / PREFIX / "review_traces").exists()
@pytest.mark.asyncio
async def test_per_ic_flush_survives_pause(workspace, monkeypatch):
seen = []
async def before_ic(ref):
seen.append(ref)
return len(seen) == 1 # allow only the first IC, then pause
await _run(workspace, monkeypatch, before_ic)
traces_dir = workspace["data"] / PREFIX / "review_traces"
files = sorted(p.name for p in traces_dir.glob("*.json"))
# Exactly the first IC (U1) reviewed before the pause; its trace persisted.
assert files == ["U1.json"]