Files
periscope/tests/test_purple_parts_resolver.py
T
Siddharth Kothari 6672d2be57 Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
2026-07-16 21:29:45 -07:00

767 lines
27 KiB
Python

"""Tests for the LCSC → MPN resolver wired into the BOM-parse stage."""
from __future__ import annotations
import pytest
def test_is_lcsc_code():
from backend.services.purple_parts import is_lcsc_code
assert is_lcsc_code("C12345") is True
assert is_lcsc_code("c123") is True
assert is_lcsc_code("C0") is True
assert is_lcsc_code("TPS62840DLCR") is False
assert is_lcsc_code("C") is False
assert is_lcsc_code("C123abc") is False
assert is_lcsc_code("") is False
assert is_lcsc_code(None) is False
assert is_lcsc_code(" C12345 ") is True
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_no_op_when_disabled(monkeypatch):
"""When purple-parts isn't configured, the helper must not touch the BOM."""
from backend.config import settings
from backend.services.pipeline import _resolve_lcsc_codes
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
bom = {
"U1": {"value": "", "footprint": "", "mpn": None, "lcsc": "C12345"},
"C1": {"value": "10uF", "footprint": "0603", "mpn": "C25804", "lcsc": None},
}
snapshot = {k: dict(v) for k, v in bom.items()}
await _resolve_lcsc_codes(ctx=None, bom=bom)
assert bom == snapshot
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_fills_mpn_from_lcsc_column(monkeypatch):
"""LCSC code in the dedicated `lcsc` column populates the empty `mpn`."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
assert sorted(codes) == ["C12345"]
return {"C12345": {"lcsc": "C12345", "mpn": "TPS62840DLCR", "manufacturer": "TI"}}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
bom = {
"U1": {"value": "", "footprint": "LQFP-48", "mpn": None, "lcsc": "C12345"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert bom["U1"]["mpn"] == "TPS62840DLCR"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_pipeline_backstop_is_lcsc_column_only(monkeypatch):
"""Pipeline-stage resolver is a backstop for the unambiguous case only:
LCSC column populated AND MPN slot empty. An LCSC-shaped value sitting
in the MPN slot is NOT resolved here — column-level upload-time
resolution is the primary path."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
called = False
async def fake_lookup(codes):
nonlocal called
called = True
return {}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
# MPN field has an LCSC-shaped value — pipeline backstop must NOT touch this.
"U2": {"value": "", "footprint": "", "mpn": "C2040", "lcsc": None},
# A real MPN starting with C must NOT be touched.
"C1": {"value": "10uF", "footprint": "0402", "mpn": "CL05B104KO5NNNC", "lcsc": None},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert called is False
assert bom["U2"]["mpn"] == "C2040"
assert bom["C1"]["mpn"] == "CL05B104KO5NNNC"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_leaves_unresolved_alone(monkeypatch):
"""A miss in purple-parts must not blank out the existing MPN/LCSC."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {"C99999999": None}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
"U3": {"value": "", "footprint": "", "mpn": None, "lcsc": "C99999999"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert bom["U3"]["mpn"] is None # unchanged
assert bom["U3"]["lcsc"] == "C99999999"
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_caches_payload_for_passive_stage(monkeypatch):
"""The resolver must stash the full LCSC payload on ctx.lcsc_data so the
downstream passive extraction can use the description/category without a
second purple-parts call."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
payload = {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R ±10% 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
}
async def fake_lookup(codes):
return {"C15850": payload}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
ctx = _Ctx()
bom = {
"C1": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
"C2": {"value": "10uF", "footprint": "0805", "mpn": None, "lcsc": "C15850"},
}
await pipeline._resolve_lcsc_codes(ctx, bom)
assert ctx.lcsc_data == {"CL21A106KAYNNNE": payload}
assert bom["C1"]["mpn"] == "CL21A106KAYNNNE"
assert bom["C2"]["mpn"] == "CL21A106KAYNNNE"
def test_detect_lcsc_column_all_c_codes():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Manufacturer Part Number,Value\nU1,C12044,\nU2,C2040,\nU3,C25804,\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is True
def test_detect_lcsc_column_mixed_returns_false():
from backend.services.purple_parts import detect_lcsc_column
csv = (b"Reference,Manufacturer Part Number\n"
b"U1,C12044\n"
b"U2,TPS62840DLCR\n")
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
def test_detect_lcsc_column_empty_returns_false():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Manufacturer Part Number\nU1,\nU2,\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
def test_detect_lcsc_column_missing_column():
from backend.services.purple_parts import detect_lcsc_column
csv = b"Reference,Value\nU1,10uF\n"
assert detect_lcsc_column(csv, "Manufacturer Part Number") is False
@pytest.mark.asyncio
async def test_resolve_lcsc_column_bytes_rewrites_csv(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {
"C12044": {"lcsc": "C12044", "mpn": "STM32F103C8T6"},
"C2040": {"lcsc": "C2040", "mpn": "RP2040"},
"C99999999": None, # unresolved
}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
csv_in = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,C12044\n"
b"U2,RP2040,QFN-56,C2040\n"
b"U3,Mystery,SOT-23,C99999999\n"
)
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
csv_in, mpn_col="Manufacturer Part Number",
)
assert updated == 2
assert lcsc_map == {"C12044": "STM32F103C8T6", "C2040": "RP2040"}
# Full payloads keyed by LCSC id (verbatim from purple-parts) so the wizard
# can short-cut DigiKey when synthesising auto_resolve_specs input.
assert set(lcsc_payloads.keys()) == {"C12044", "C2040"}
assert lcsc_payloads["C12044"]["mpn"] == "STM32F103C8T6"
out_text = out_bytes.decode()
# Resolved rows updated
assert "STM32F103C8T6" in out_text
assert "RP2040" in out_text
# Unresolved row preserved as-is
assert "C99999999" in out_text
# Column order and other columns preserved
assert out_text.splitlines()[0] == "Reference,Value,Footprint,Manufacturer Part Number"
assert "LQFP-48" in out_text
@pytest.mark.asyncio
async def test_resolve_lcsc_column_bytes_no_op_when_disabled(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
csv_in = b"Reference,Manufacturer Part Number\nU1,C12044\n"
out_bytes, updated, lcsc_map, lcsc_payloads = await purple_parts.resolve_lcsc_column_bytes(
csv_in, mpn_col="Manufacturer Part Number",
)
assert updated == 0
assert lcsc_map == {}
assert lcsc_payloads == {}
assert out_bytes == csv_in
@pytest.mark.asyncio
async def test_resolve_lcsc_codes_skips_rows_with_real_mpn(monkeypatch):
"""Rows that already have a real MPN should not be re-resolved."""
from backend.config import settings
from backend.services import pipeline, purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
call_count = 0
async def fake_lookup(codes):
nonlocal call_count
call_count += 1
return {c: None for c in codes}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
monkeypatch.setattr(pipeline.broker, "publish", lambda *a, **kw: None)
class _Ctx:
project_id = "p1"
lcsc_data: dict = {}
bom = {
"C1": {"value": "10uF", "footprint": "0603", "mpn": "GRM188R71C104KA01D", "lcsc": "C14663"},
}
await pipeline._resolve_lcsc_codes(_Ctx(), bom)
assert call_count == 0 # nothing to resolve
assert bom["C1"]["mpn"] == "GRM188R71C104KA01D"
# ---------------------------------------------------------------------------
# Upload-time classification + lcsc_payloads caching + /lcsc/resolve-passive
# ---------------------------------------------------------------------------
def _make_test_client(tmp_path):
"""Build a FastAPI TestClient backed by a fresh LocalStorageBackend.
Mirrors the production app's middleware stack but defaults the user_id
to ``"local"`` (auth disabled) so we can hit endpoints without Clerk.
"""
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 test_upload_bom_classifies_components(tmp_path, monkeypatch):
"""Upload-time component classification populates ``component_mpns``
for a mixed-type BOM, even without LCSC resolution running."""
from backend.config import settings
# Disable purple-parts so we exercise the no-LCSC path.
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
client = _make_test_client(tmp_path)
# Create project and upload a mixed BOM.
resp = client.post("/api/projects", json={"name": "mixed"})
assert resp.status_code == 200
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
b"R1,10k,0603,RC0603FR-0710KL\n"
b"D1,Diode,SOD-123,1N4148WS\n"
)
files = {"file": ("bom.csv", csv, "text/csv")}
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files=files,
)
assert resp.status_code == 200, resp.text
# The classification should land on ProjectMeta.component_mpns.
resp = client.get(f"/api/projects/{project_id}")
meta = resp.json()
assert meta["component_mpns"] is not None
assert meta["component_mpns"]["ic"] == ["STM32F103C8T6"]
assert sorted(meta["component_mpns"]["passive"]) == sorted(
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
)
assert meta["component_mpns"]["simple"] == ["1N4148WS"]
def test_upload_bom_populates_lcsc_payloads(tmp_path, monkeypatch):
"""When the MPN column is detected as all-LCSC ids, the full purple-parts
payloads must be cached on ProjectMeta.lcsc_payloads."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_lookup(codes):
return {
"C15850": {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
"C14663": {
"lcsc": "C14663",
"mpn": "GRM188R71C104KA01D",
"manufacturer": "Murata",
"package": "0603",
"description": "0.1uF 16V X7R 0603 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "lcsc"})
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"C1,10uF,0805,C15850\n"
b"C2,0.1uF,0603,C14663\n"
)
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["lcsc_resolved"] == 2
assert body["lcsc_to_mpn"] == {
"C15850": "CL21A106KAYNNNE", "C14663": "GRM188R71C104KA01D",
}
meta = client.get(f"/api/projects/{project_id}").json()
assert meta["lcsc_payloads"] is not None
assert set(meta["lcsc_payloads"].keys()) == {"C15850", "C14663"}
p = meta["lcsc_payloads"]["C15850"]
assert p["mpn"] == "CL21A106KAYNNNE"
assert p["description"].startswith("10uF")
# Component classification ran on the resolved BOM, so the MPNs are the
# real manufacturer parts (not the LCSC codes).
assert sorted(meta["component_mpns"]["passive"]) == sorted(
["CL21A106KAYNNNE", "GRM188R71C104KA01D"]
)
def test_lcsc_resolve_passive_404_when_lcsc_id_missing(tmp_path, monkeypatch):
"""404 when the requested lcsc_id isn't in the project's cached payloads."""
from backend.config import settings
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "p"})
project_id = resp.json()["id"]
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C99999999"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch):
"""First call resolves via auto_resolve_specs (mocked) and writes the
project model + library copy. Second call short-circuits with cached=True
and does not invoke auto_resolve_specs again."""
from backend.config import settings
from backend.pinscopex.models import CapacitorSpecs, ComponentModel
from backend.services import purple_parts
from backend.services import extraction as extraction_svc
# Billing modules are absent in the open-source repo; the endpoint under
# test only charges when billing is enabled, so skip there.
credits_svc = pytest.importorskip("backend.services.credits")
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
payload = {
"lcsc": "C15850",
"mpn": "CL21A106KAYNNNE",
"manufacturer": "Samsung",
"package": "0805",
"description": "10uF 25V X5R 0805 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
}
async def fake_lookup(codes):
return {"C15850": payload}
monkeypatch.setattr(purple_parts, "lookup_lcsc_batch", fake_lookup)
call_count = {"n": 0}
async def fake_auto_resolve_specs(**kwargs):
call_count["n"] += 1
# Drop a synthetic api log entry so the credit-charge path runs.
api_logger = kwargs.get("api_logger")
if api_logger is not None:
api_logger.log(
stage="auto_resolve", identifier=kwargs["mpn"],
model="claude-haiku-4-5-20251001",
input_tokens=500, output_tokens=200,
cache_creation_input_tokens=0, cache_read_input_tokens=0,
duration_ms=100, stop_reason="end_turn", turns=1,
)
return ComponentModel(
mpn=kwargs["mpn"],
specs=CapacitorSpecs(
value_farads=10e-6,
value_formatted="10uF",
voltage_rating_v="25V",
tolerance="±10%",
dielectric="X5R",
package="0805",
),
)
monkeypatch.setattr(extraction_svc, "auto_resolve_specs", fake_auto_resolve_specs)
# Need credits to charge against — grant enough.
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
credits_svc.grant(storage, "local", 100.0, "trial_grant")
from backend.main import app
from fastapi.testclient import TestClient
app.state.storage = storage
client = TestClient(app)
resp = client.post("/api/projects", json={"name": "lcsc"})
project_id = resp.json()["id"]
csv = b"Reference,Value,Footprint,Manufacturer Part Number\nC1,10uF,0805,C15850\n"
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
# First call: resolves via mocked auto_resolve_specs.
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C15850"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["mpn"] == "CL21A106KAYNNNE"
assert body["lcsc_id"] == "C15850"
assert body["cached"] is False
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 1
# Library copy should exist for cross-project reuse.
from backend.pinscopex.utils import safe_mpn
safe = safe_mpn("CL21A106KAYNNNE")
assert storage.exists(f"library/passives/{safe}.json")
# Second call: short-circuits via the project model file.
resp = client.post(
f"/api/projects/{project_id}/lcsc/resolve-passive",
json={"lcsc_id": "C15850"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["cached"] is True
assert body["model"]["mpn"] == "CL21A106KAYNNNE"
assert call_count["n"] == 1 # not invoked again
# ---------------------------------------------------------------------------
# Reverse MPN → LCSC lookup (by-mpn) + upload-time real-MPN passive enrich
# ---------------------------------------------------------------------------
def test_pick_exact_matches_case_and_space_insensitive():
from backend.services.purple_parts import _pick_exact
cands = [
{"mpn": "OTHER-PART", "lcsc": "C1"},
{"mpn": "tps62840 dlcr", "lcsc": "C2040"},
]
hit = _pick_exact("TPS62840DLCR", cands)
assert hit is not None
assert hit["lcsc"] == "C2040"
def test_pick_exact_rejects_prefix_only():
from backend.services.purple_parts import _pick_exact
# Only a longer family part is returned — not an exact match → reject so it
# can't pollute the shared library.
cands = [{"mpn": "RC0603FR-0710KLZZ", "lcsc": "C999"}]
assert _pick_exact("RC0603FR-0710KL", cands) is None
assert _pick_exact("X", []) is None
class _FakeResp:
def __init__(self, data):
self._data = data
def raise_for_status(self):
return None
def json(self):
return self._data
class _FakeAsyncClient:
"""Stand-in for httpx.AsyncClient used by lookup_mpn_batch — serves the
POST /v1/parts/by-mpn/batch endpoint from a {mpn: [candidates]} routes map."""
def __init__(self, routes):
self.routes = routes
self.posts: list[dict] = [] # captured request bodies
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, headers=None, json=None):
body = json or {}
self.posts.append(body)
mpns = body.get("mpns", [])
return _FakeResp({"results": {m: self.routes.get(m, []) for m in mpns}})
@pytest.mark.asyncio
async def test_lookup_mpn_batch_disabled_is_noop(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "")
monkeypatch.setattr(settings, "purple_parts_api_key", "")
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR"])
assert out == {"TPS62840DLCR": None}
@pytest.mark.asyncio
async def test_lookup_mpn_batch_exact_dedup_and_miss(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def fake_token():
return "tok"
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
routes = {
"TPS62840DLCR": [{"lcsc": "C2040", "mpn": "TPS62840DLCR", "description": "buck"}],
"RC0603FR-0710KL": [
{"lcsc": "C999", "mpn": "RC0603FR-0710KLZZ", "description": "prefix only"}
],
"MISSING1": [],
}
fake = _FakeAsyncClient(routes)
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
out = await purple_parts.lookup_mpn_batch(
["TPS62840DLCR", "TPS62840DLCR", "RC0603FR-0710KL", "MISSING1"]
)
assert out["TPS62840DLCR"]["lcsc"] == "C2040" # exact hit kept
assert out["RC0603FR-0710KL"] is None # prefix-only rejected
assert out["MISSING1"] is None # real miss
# Deduped into a single batch POST; TPS appears once in the request body.
assert len(fake.posts) == 1
assert fake.posts[0]["mpns"].count("TPS62840DLCR") == 1
@pytest.mark.asyncio
async def test_lookup_mpn_batch_no_token_returns_all_none(monkeypatch):
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
async def no_token():
return None
monkeypatch.setattr(purple_parts, "_get_identity_token", no_token)
out = await purple_parts.lookup_mpn_batch(["TPS62840DLCR", "RP2040"])
assert out == {"TPS62840DLCR": None, "RP2040": None}
@pytest.mark.asyncio
async def test_lookup_mpn_batch_chunks_large_input(monkeypatch):
"""More than _BATCH_SIZE unique MPNs are split across multiple POSTs."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
monkeypatch.setattr(purple_parts, "_BATCH_SIZE", 2)
async def fake_token():
return "tok"
monkeypatch.setattr(purple_parts, "_get_identity_token", fake_token)
routes = {m: [{"lcsc": f"C{i}", "mpn": m}] for i, m in enumerate(["A", "B", "C"])}
fake = _FakeAsyncClient(routes)
monkeypatch.setattr(purple_parts.httpx, "AsyncClient", lambda *a, **kw: fake)
out = await purple_parts.lookup_mpn_batch(["A", "B", "C"])
assert {k: v["lcsc"] for k, v in out.items()} == {"A": "C0", "B": "C1", "C": "C2"}
assert len(fake.posts) == 2 # 3 MPNs / chunk size 2
assert [len(p["mpns"]) for p in fake.posts] == [2, 1]
def test_upload_bom_enriches_real_mpn_passives_via_by_mpn(tmp_path, monkeypatch):
"""For a genuine MPN column, upload reverse-looks-up passives in the LCSC
catalogue and caches lcsc_to_mpn + lcsc_payloads so the wizard resolves them
through the same machinery as the LCSC-column flow. ICs/simple are excluded,
catalogue misses fall through to the pipeline, and the CSV is not rewritten."""
from backend.config import settings
from backend.services import purple_parts
monkeypatch.setattr(settings, "purple_parts_url", "https://example.test")
monkeypatch.setattr(settings, "purple_parts_api_key", "test-key")
captured = {}
async def fake_by_mpn(mpns, **kw):
captured["mpns"] = list(mpns)
return {
"CL10A106KQ8NNNC": {
"lcsc": "C1525",
"mpn": "CL10A106KQ8NNNC",
"manufacturer": "Samsung",
"package": "0603",
"description": "10uF 6.3V X5R 0603 MLCC",
"category": "Capacitors",
"subcategory": "MLCC - SMD/SMT",
},
"RC0603FR-0710KL": None, # catalogue miss
}
monkeypatch.setattr(purple_parts, "lookup_mpn_batch", fake_by_mpn)
client = _make_test_client(tmp_path)
resp = client.post("/api/projects", json={"name": "realmpn"})
project_id = resp.json()["id"]
csv = (
b"Reference,Value,Footprint,Manufacturer Part Number\n"
b"U1,STM32,LQFP-48,STM32F103C8T6\n"
b"C1,10uF,0603,CL10A106KQ8NNNC\n"
b"R1,10k,0603,RC0603FR-0710KL\n"
b"D1,Diode,SOD-123,1N4148WS\n"
)
resp = client.post(
f"/api/projects/{project_id}/upload/bom",
files={"file": ("bom.csv", csv, "text/csv")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["lcsc_resolved"] == 0 # no CSV rewrite
assert body["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}
# Only passives are looked up (IC + simple excluded).
assert sorted(captured["mpns"]) == sorted(
["CL10A106KQ8NNNC", "RC0603FR-0710KL"]
)
meta = client.get(f"/api/projects/{project_id}").json()
assert set(meta["lcsc_payloads"].keys()) == {"C1525"}
assert meta["lcsc_payloads"]["C1525"]["mpn"] == "CL10A106KQ8NNNC"
assert meta["lcsc_to_mpn"] == {"C1525": "CL10A106KQ8NNNC"}