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:
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
|
||||
@@ -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())) == []
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, {}) == []
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")]}
|
||||
@@ -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
|
||||
@@ -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()) == []
|
||||
@@ -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 == []
|
||||
@@ -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"}
|
||||
@@ -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:")
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user