diff --git a/backend/pinscopex/validation_tools.py b/backend/pinscopex/validation_tools.py index 96c203a..c75461c 100644 --- a/backend/pinscopex/validation_tools.py +++ b/backend/pinscopex/validation_tools.py @@ -317,18 +317,24 @@ def _resolve_neighbor_pdf( Mirrors validation._find_pdf's local-then-library lookup so neighbor datasheets follow the same resolution rules as the IC under review. """ - safe = safe_mpn(mpn) - local = state.pdf_dir / f"{safe}.pdf" - if local.is_file(): + from backend.services.datasheet_finder import find_local_pdf + + local = find_local_pdf(state.pdf_dir, mpn) + if local is not None and local.is_file(): + wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf" + if local.resolve() != wanted.resolve() and not wanted.is_file(): + wanted.write_bytes(local.read_bytes()) + return wanted return local if state.storage is not None: try: from backend.services import projects as proj_svc lib_key = proj_svc.library_has_datasheet(state.storage, mpn) if lib_key: - state.storage.download_to_local(lib_key, local) - if local.is_file(): - return local + wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf" + state.storage.download_to_local(lib_key, wanted) + if wanted.is_file(): + return wanted except Exception: log.exception("excerpt: library lookup failed for %s", mpn) return None diff --git a/backend/services/datasheet_finder.py b/backend/services/datasheet_finder.py index 61a22fa..b8d3383 100644 --- a/backend/services/datasheet_finder.py +++ b/backend/services/datasheet_finder.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging import re from dataclasses import dataclass +from pathlib import Path import httpx @@ -138,6 +139,34 @@ def mpn_catalog_match(query: str, candidate: str) -> bool: return c.startswith(q) and len(c) > len(q) +def find_local_pdf(pdf_dir: Path, mpn: str) -> Path | None: + """Find a datasheet PDF whose filename is this MPN or a catalog alias. + + ``ESP32-S31-WROOM-3`` matches ``ESP32-S31-WROOM-3-N16R16V.pdf`` and the + reverse — packing / flash-size suffixes, not sibling dies (CH340 vs CH340E). + """ + from backend.pinscopex.utils import safe_mpn + + if not mpn or not pdf_dir.is_dir(): + return None + for name in mpn_query_variants(mpn) or [mpn]: + hit = pdf_dir / f"{safe_mpn(name)}.pdf" + if hit.is_file(): + return hit + want = _alnum(mpn) + if len(want) < _MIN_FAMILY_LEN: + return None + family_hit: Path | None = None + for hit in pdf_dir.glob("*.pdf"): + stem = hit.stem + if mpn_matches(mpn, stem) or mpn_catalog_match(mpn, stem) or mpn_catalog_match(stem, mpn): + got = _alnum(stem) + if got == want or mpn_matches(mpn, stem): + return hit + family_hit = family_hit or hit + return family_hit + + def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None: exact: dict | None = None loose: dict | None = None diff --git a/backend/services/passive_from_mpn.py b/backend/services/passive_from_mpn.py new file mode 100644 index 0000000..5300fab --- /dev/null +++ b/backend/services/passive_from_mpn.py @@ -0,0 +1,139 @@ +"""Decode common chip R/C MPNs into typed specs without an LLM. + +Only encodings that carry package + value (and voltage for capacitors when +the manufacturer puts it in the code) are accepted. Incomplete BOM-value +guesses stay out of the shared library. +""" + +from __future__ import annotations + +import re + +from backend.pinscopex.models import ComponentModel, SimpleComponentSpecs +from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs +from backend.services.passive_from_distributor import _spice + +_SIZE = r"(?:0201|0402|0603|0805|1206|1210|1812|2010|2512)" +_DIEL = r"(?:C0G|NP0|CG|X8R|X7R|X7S|X6S|X5R|Y5V)" +_TOL = { + "B": "±0.1%", + "C": "±0.25%", + "D": "±0.5%", + "F": "±1%", + "G": "±2%", + "J": "±5%", + "K": "±10%", + "M": "±20%", +} +_AVX_V = { + "4": "4V", + "6": "6.3V", + "Z": "10V", + "Y": "16V", + "3": "25V", + "5": "50V", + "1": "100V", + "2": "200V", + "7": "500V", +} +_AVX_DIEL = {"A": "C0G", "C": "X7R", "D": "X5R", "Z": "Y5V"} +_DIEL_NORM = {"CG": "C0G", "NP0": "C0G"} + +_WALSIN_CAP = re.compile( + rf"^({_SIZE})({_DIEL})(\d{{3}})([{''.join(_TOL)}])(\d{{3}}|[0-9]R[0-9])", + re.IGNORECASE, +) +_AVX_CAP = re.compile( + rf"^({_SIZE})([46ZY35127])([{''.join(_AVX_DIEL)}])(\d{{3}})([{''.join(_TOL)}])", + re.IGNORECASE, +) +_CHIP_R = re.compile( + rf"^(?:FRC)?({_SIZE})(?:W\d)?([{''.join('FJKG')}])(\d{{4}})", + re.IGNORECASE, +) + + +def _eia3_pf(digits: str) -> float: + return float(int(digits[:2]) * (10 ** int(digits[2]))) + + +def _eia3_volts(code: str) -> str | None: + code = code.upper() + if "R" in code: + try: + return f"{float(code.replace('R', '.')):g}V" + except ValueError: + return None + if len(code) != 3 or not code.isdigit(): + return None + volts = int(code[:2]) * (10 ** int(code[2])) + return f"{volts}V" + + +def _eia4_ohm(digits: str) -> float: + return float(int(digits[:3]) * (10 ** int(digits[3]))) + + +def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None: + specs = SimpleComponentSpecs( + specs_type="passive", + component_subtype=subtype, + values=values, + ) + try: + typed = simple_to_typed_passive_specs(specs) + except (ValueError, TypeError): + return None + return ComponentModel(mpn=mpn, specs=typed) + + +def specs_from_mpn(mpn: str) -> ComponentModel | None: + """Return a ComponentModel when the MPN itself encodes enough specs.""" + raw = (mpn or "").strip() + if not raw: + return None + + m = _WALSIN_CAP.match(raw) + if m: + size, diel, cap, tol, volt = m.groups() + pf = _eia3_pf(cap) + values = { + "value_farads": _spice(str(pf), "p", "F") if pf else None, + "package": size.upper(), + "dielectric": _DIEL_NORM.get(diel.upper(), diel.upper()), + "tolerance": _TOL[tol.upper()], + } + rated = _eia3_volts(volt) + if rated: + values["voltage_rating_v"] = rated + values["value_formatted"] = values["value_farads"] + if values["value_farads"]: + return _model(raw, "passive.capacitor.ceramic", values) + + m = _AVX_CAP.match(raw) + if m: + size, vcode, diel, cap, tol = m.groups() + pf = _eia3_pf(cap) + values = { + "value_farads": _spice(str(pf), "p", "F"), + "value_formatted": _spice(str(pf), "p", "F"), + "package": size.upper(), + "dielectric": _AVX_DIEL[diel.upper()], + "tolerance": _TOL[tol.upper()], + "voltage_rating_v": _AVX_V[vcode.upper()], + } + return _model(raw, "passive.capacitor.ceramic", values) + + m = _CHIP_R.match(raw) + if m: + size, tol, code = m.groups() + ohms = _eia4_ohm(code) + values = { + "value_ohms": _spice(str(ohms), None, "ohm"), + "value_formatted": _spice(str(ohms), None, "ohm"), + "package": size.upper(), + "tolerance": _TOL[tol.upper()], + } + return _model(raw, "passive.resistor.thick_film", values) + + return None diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index f3ab1c3..9b655fa 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -681,15 +681,16 @@ async def _ensure_local_datasheet( """ if pdf_path.is_file(): return True - from backend.services.datasheet_finder import find_datasheet, mpn_query_variants + from backend.services.datasheet_finder import find_datasheet, find_local_pdf, mpn_query_variants + + alt = find_local_pdf(pdf_path.parent, mpn) + if alt is not None and alt.is_file(): + if alt.resolve() != pdf_path.resolve(): + pdf_path.parent.mkdir(parents=True, exist_ok=True) + pdf_path.write_bytes(alt.read_bytes()) + return True for name in mpn_query_variants(mpn) or [mpn]: - alt = pdf_path.parent / f"{safe_mpn(name)}.pdf" - if alt.is_file(): - if alt.resolve() != pdf_path.resolve(): - pdf_path.parent.mkdir(parents=True, exist_ok=True) - pdf_path.write_bytes(alt.read_bytes()) - return True lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, name) if lib_ds_key: ctx.storage.download_to_local(lib_ds_key, pdf_path) @@ -1116,6 +1117,16 @@ async def _catalog_resolve_unresolved_passives( except Exception as e: first_error = str(e) + if model is None: + from backend.services.passive_from_mpn import specs_from_mpn + model = specs_from_mpn(mpn) + if model is not None: + resolved_via = "mpn" + broker.publish(ctx.project_id, "step_update", + {"stage": "passive_extraction", + "substep": mpn, "status": "running", + "detail": "decoded from MPN"}) + if model is None: bom_value = ctx.passive_values.get(mpn, "").strip() refs = ctx.passive_mpns.get(mpn, []) @@ -1152,7 +1163,7 @@ async def _catalog_resolve_unresolved_passives( model_path.write_text(model.model_dump_json(indent=2) + "\n") model_key = f"{ctx.ws.prefix}/models/{safe}.json" ctx.storage.upload_from_local(model_path, model_key) - if resolved_via in ("digikey", "lcsc"): + if resolved_via in ("digikey", "lcsc", "mpn"): proj_svc.save_to_library( ctx.storage, model_key, "passives", f"{safe}.json", ) @@ -1161,6 +1172,7 @@ async def _catalog_resolve_unresolved_passives( detail = { "lcsc": "auto-resolved via LCSC", "digikey": "auto-resolved via DigiKey", + "mpn": "decoded from MPN (saved to library)", "value": "resolved from BOM value (not saved to library)", }.get(resolved_via, "resolved") broker.publish(ctx.project_id, "step_update", @@ -1502,14 +1514,16 @@ async def _stage_validation(ctx: PipelineContext) -> None: # Snapshot the full review queue so pause checkpoints can show what's left. # Mirrors the filter in validate_design_async: ICs with a PDF available. + from backend.services.datasheet_finder import find_local_pdf + planned_refs: list[str] = [] for ref, comp in ctx.graph.components.items(): if comp.component_type != ComponentType.IC: continue - mpn = comp.mpn or comp.value + mpn = (comp.mpn or "").strip() or (comp.value or "").strip() if not mpn: continue - if (ds_dir / f"{safe_mpn(mpn)}.pdf").is_file(): + if find_local_pdf(ds_dir, mpn) is not None: planned_refs.append(ref) ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key) diff --git a/backend/services/projects.py b/backend/services/projects.py index d84e6a4..26f74e1 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -775,6 +775,31 @@ def save_to_library( return dst_key +def _specs_param_count(specs: dict) -> int: + if not isinstance(specs, dict): + return 0 + values = specs.get("values") + if isinstance(values, dict): + return sum(1 for v in values.values() if v not in (None, "", [])) + skip = {"specs_type", "component_subtype"} + return sum( + 1 for k, v in specs.items() + if k not in skip and v not in (None, "", []) + ) + + +def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict: + mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "") + specs = data.get("specs", {}) or {} + return { + "mpn": mpn, + "type": row_type, + "specs_type": specs.get("specs_type", ""), + "subtype": specs.get("component_subtype", ""), + "param_count": _specs_param_count(specs), + } + + def list_library_catalog(storage: StorageBackend) -> dict: """List ICs, passive patterns, discrete specs, and datasheet refs. @@ -826,26 +851,24 @@ def list_library_catalog(storage: StorageBackend) -> dict: continue simple_models: list[dict] = [] + passive_parts: list[dict] = [] seen_model_mpns: set[str] = set() - for prefix in ("library/models/", "library/passives/"): + for prefix, row_type, dest in ( + ("library/passives/", "passive_part", passive_parts), + ("library/models/", "simple", simple_models), + ): for key in storage.list_prefix(prefix): if not key.endswith(".json"): continue try: data = storage.read_json(key) - mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "") + row = _catalog_model_row(data, key, row_type=row_type) + mpn = row["mpn"] if mpn in seen_model_mpns: continue seen_model_mpns.add(mpn) - specs = data.get("specs", {}) or {} - simple_models.append({ - "mpn": mpn, - "type": "simple", - "specs_type": specs.get("specs_type", ""), - "subtype": specs.get("component_subtype", ""), - "param_count": len(specs.get("values", {}) or {}), - "has_datasheet": bool(resolve_datasheet(storage, mpn)), - }) + row["has_datasheet"] = bool(resolve_datasheet(storage, mpn)) + dest.append(row) except Exception: continue @@ -871,11 +894,13 @@ def list_library_catalog(storage: StorageBackend) -> dict: ics.sort(key=lambda r: r["mpn"].lower()) passives.sort(key=lambda r: r["mpn"].lower()) + passive_parts.sort(key=lambda r: r["mpn"].lower()) simple_models.sort(key=lambda r: r["mpn"].lower()) datasheets.sort(key=lambda r: r["mpn"].lower()) return { "ics": ics, "passives": passives, + "passive_parts": passive_parts, "simple": simple_models, "datasheets": datasheets, } diff --git a/backend/services/validation.py b/backend/services/validation.py index 36ffb7d..1ac0850 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -571,27 +571,29 @@ def _find_pdf( """Find the datasheet PDF for an MPN. Checks local dir first, then tries to download from the library. """ - from backend.services.datasheet_finder import mpn_query_variants + from backend.services.datasheet_finder import find_local_pdf from backend.pinscopex.utils import safe_mpn as _safe - names = mpn_query_variants(mpn) or [mpn] - for name in names: - local = pdf_dir / f"{_safe(name)}.pdf" - if local.is_file(): - wanted = pdf_dir / f"{_safe(mpn)}.pdf" - if local != wanted and not wanted.is_file(): - wanted.write_bytes(local.read_bytes()) - return wanted - return local + mpn = (mpn or "").strip() + if not mpn: + return None + + local = find_local_pdf(pdf_dir, mpn) + if local is not None and local.is_file(): + wanted = pdf_dir / f"{_safe(mpn)}.pdf" + if local.resolve() != wanted.resolve() and not wanted.is_file(): + wanted.write_bytes(local.read_bytes()) + return wanted + return local if storage: from backend.services import projects as proj_svc lib_key = proj_svc.library_has_datasheet(storage, mpn) if lib_key: - local = pdf_dir / f"{_safe(mpn)}.pdf" - storage.download_to_local(lib_key, local) - if local.is_file(): - return local + wanted = pdf_dir / f"{_safe(mpn)}.pdf" + storage.download_to_local(lib_key, wanted) + if wanted.is_file(): + return wanted return None @@ -665,7 +667,12 @@ async def validate_design_async( for ref, comp in sorted(graph.components.items()): if comp.component_type != ComponentType.IC: continue - mpn = comp.mpn or comp.value + mpn = (comp.mpn or "").strip() or (comp.value or "").strip() + if not mpn: + not_reviewed.append({"designator": ref, "reason": "no MPN in BOM"}) + if on_progress: + await on_progress(ref, 0, "skipped", "no MPN in BOM") + continue pdf = _find_pdf(mpn, pdf_dir_path, storage=storage) if pdf: ic_tasks.append((ref, str(pdf))) diff --git a/frontend/src/app/(app)/admin/page.tsx b/frontend/src/app/(app)/admin/page.tsx index 2cf5a6f..53a4b22 100644 --- a/frontend/src/app/(app)/admin/page.tsx +++ b/frontend/src/app/(app)/admin/page.tsx @@ -632,7 +632,11 @@ function ComponentsPanel() { if (!prev) return prev; if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) }; if (type === "passive") return { ...prev, passives: prev.passives.filter((c) => c.mpn !== name) }; - return { ...prev, simple: prev.simple.filter((c) => c.mpn !== name) }; + return { + ...prev, + simple: prev.simple.filter((c) => c.mpn !== name), + passive_parts: (prev.passive_parts ?? []).filter((c) => c.mpn !== name), + }; }); reload(); } catch (e) { @@ -678,6 +682,12 @@ function ComponentsPanel() { c.subtype.toLowerCase().includes(lf) || c.description.toLowerCase().includes(lf), ); + const filteredPassiveParts = (data.passive_parts ?? []).filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.specs_type.toLowerCase().includes(lf), + ); const filteredSimple = (data.simple ?? []).filter( (c) => c.mpn.toLowerCase().includes(lf) || @@ -699,6 +709,13 @@ function ComponentsPanel() { {data.passives.length} Passive Patterns + {(data.passive_parts?.length ?? 0) > 0 && ( +
+ + {data.passive_parts.length} + Passive MPNs +
+ )} {(data.simple?.length ?? 0) > 0 && (
@@ -827,6 +844,57 @@ function ComponentsPanel() {
)} + {/* Per-MPN passive specs */} + {filteredPassiveParts.length > 0 && ( +
+

Passive MPNs

+
+ + + + + + + + + + + {filteredPassiveParts.map((s) => ( + handleRowClick("simple", s.mpn)}> + + + + + + + ))} + +
MPNTypeSubtypeParams +
{s.mpn} + {s.specs_type || "passive"} + + {s.subtype ? ( + {s.subtype} + ) : ( + - + )} + {s.param_count} + +
+
+
+ )} + {/* Simple component specs table */} {filteredSimple.length > 0 && (
@@ -878,7 +946,7 @@ function ComponentsPanel() {
)} - {filteredICs.length === 0 && filteredPassives.length === 0 && filteredSimple.length === 0 && ( + {filteredICs.length === 0 && filteredPassives.length === 0 && filteredPassiveParts.length === 0 && filteredSimple.length === 0 && (

diff --git a/frontend/src/app/(app)/library/page.tsx b/frontend/src/app/(app)/library/page.tsx index 1f2636d..68abcaa 100644 --- a/frontend/src/app/(app)/library/page.tsx +++ b/frontend/src/app/(app)/library/page.tsx @@ -55,6 +55,17 @@ export default function LibraryPage() { ), [data, lf], ); + const passiveParts = useMemo( + () => + (data?.passive_parts ?? []).filter( + (c) => + !lf || + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.specs_type.toLowerCase().includes(lf), + ), + [data, lf], + ); const simple = useMemo( () => (data?.simple ?? []).filter( @@ -108,6 +119,7 @@ export default function LibraryPage() { !data || (data.ics.length === 0 && data.passives.length === 0 && + (data.passive_parts ?? []).length === 0 && data.simple.length === 0 && data.datasheets.length === 0); @@ -143,8 +155,15 @@ export default function LibraryPage() { + + {(data.passive_parts ?? []).length} + + passives + + + {data.passives.length} - passive series + series @@ -169,7 +188,7 @@ export default function LibraryPage() { ICs ({ics.length}) - Passives ({passives.length}) + Passives ({passiveParts.length + passives.length}) Discrete ({simple.length}) @@ -225,40 +244,109 @@ export default function LibraryPage() { )} - - {passives.length === 0 ? ( - + + {passiveParts.length === 0 && passives.length === 0 ? ( + ) : ( -

- - - - - - - - - - {passives.map((p) => ( - - - - - - ))} - -
SeriesType - Description -
{p.mpn} - {p.subtype ? ( - {p.subtype} - ) : ( - - )} - - {p.description || "—"} -
-
+ <> + {passiveParts.length > 0 && ( +
+ + + + + + + + + + + {passiveParts.map((p) => ( + + + + + + + ))} + +
+ MPN + + Type + + Params + + PDF +
+ {p.mpn} + + + {p.subtype || p.specs_type || "passive"} + + + {p.param_count} + + {p.has_datasheet ? ( + + ) : ( + + — + + )} +
+
+ )} + {passives.length > 0 && ( +
+

+ Series patterns — one regex covers cousin MPNs +

+
+ + + + + + + + + + {passives.map((p) => ( + + + + + + ))} + +
+ Series + + Type + + Description +
+ {p.mpn} + + {p.subtype ? ( + {p.subtype} + ) : ( + + — + + )} + + {p.description || "—"} +
+
+
+ )} + )} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 541250e..5189f4a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -389,6 +389,15 @@ export interface LibraryPassive { regex: string; } +export interface LibraryPassivePart { + mpn: string; + type: "passive_part"; + specs_type: string; + subtype: string; + param_count: number; + has_datasheet: boolean; +} + export interface LibrarySimple { mpn: string; type: "simple"; @@ -408,6 +417,7 @@ export interface LibraryDatasheet { export interface LibraryCatalog { ics: LibraryIC[]; passives: LibraryPassive[]; + passive_parts?: LibraryPassivePart[]; simple: LibrarySimple[]; datasheets: LibraryDatasheet[]; } @@ -606,6 +616,7 @@ export interface AdminSimple { export interface AdminComponents { ics: AdminIC[]; passives: AdminPassive[]; + passive_parts?: AdminSimple[]; simple: AdminSimple[]; } diff --git a/tests/test_library.py b/tests/test_library.py index ee0acb0..e7a6fdd 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -78,7 +78,9 @@ def test_library_catalog_lists_ics_passives_and_pdfs(storage): assert cat["ics"][0]["pin_count"] == 2 assert cat["ics"][0]["has_datasheet"] is True assert cat["passives"][0]["mpn"] == "Samsung CL10" - assert cat["simple"][0]["mpn"] == "CL10B474KA8NNNC" + assert cat["passive_parts"][0]["mpn"] == "CL10B474KA8NNNC" + assert cat["passive_parts"][0]["param_count"] >= 1 + assert cat["simple"] == [] assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"]) diff --git a/tests/test_passive_from_mpn.py b/tests/test_passive_from_mpn.py new file mode 100644 index 0000000..d6b8e11 --- /dev/null +++ b/tests/test_passive_from_mpn.py @@ -0,0 +1,65 @@ +from pathlib import Path + +from backend.services.datasheet_finder import find_local_pdf +from backend.services.passive_from_mpn import specs_from_mpn + + +def test_walsin_c0g_18pf(): + model = specs_from_mpn("0805CG180J500NT") + assert model is not None + specs = model.specs + assert specs.specs_type == "capacitor" + assert abs(specs.value_farads - 18e-12) < 1e-15 + assert specs.package == "0805" + assert specs.dielectric == "C0G" + assert specs.tolerance == "±5%" + assert specs.voltage_rating_v == "50V" + + +def test_avx_c0g_150pf(): + model = specs_from_mpn("08055A151FAT2A") + assert model is not None + specs = model.specs + assert abs(specs.value_farads - 150e-12) < 1e-15 + assert specs.package == "0805" + assert specs.dielectric == "C0G" + assert specs.voltage_rating_v == "50V" + + +def test_chip_resistor_from_mpn(): + model = specs_from_mpn("FRC0805F1212TS") + assert model is not None + assert abs(model.specs.value_ohms - 12100) < 0.1 + assert model.specs.package == "0805" + assert model.specs.tolerance == "±1%" + + model = specs_from_mpn("0805W8F2201T5E") + assert model is not None + assert abs(model.specs.value_ohms - 2200) < 0.1 + + +def test_skips_murata_and_bare_value(): + assert specs_from_mpn("GRM21A5C2J200JA01") is None + assert specs_from_mpn("18pF") is None + assert specs_from_mpn("CH340E") is None + + +def test_find_local_pdf_family_vs_orderable(tmp_path: Path): + pdf = tmp_path / "ESP32-S31-WROOM-3-N16R16V.pdf" + pdf.write_bytes(b"%PDF-1.4\n" + b"x" * 100) + hit = find_local_pdf(tmp_path, "ESP32-S31-WROOM-3") + assert hit is not None + assert hit.name == pdf.name + + other = tmp_path / "only" + other.mkdir() + family = other / "ESP32-S31-WROOM-3.pdf" + family.write_bytes(b"%PDF-1.4\n" + b"y" * 100) + hit = find_local_pdf(other, "ESP32-S31-WROOM-3-N16R16V") + assert hit is not None + assert hit.name == family.name + + +def test_find_local_pdf_does_not_steal_sibling_die(tmp_path: Path): + (tmp_path / "CH340E.pdf").write_bytes(b"%PDF-1.4\n" + b"x" * 100) + assert find_local_pdf(tmp_path, "CH340") is None