Show cached passives in the library and match IC PDFs by family MPN.
Decode EIA/Yageo/AVX chip codes into library specs, and find datasheets when the file is stored under the orderable catalog name. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -317,18 +317,24 @@ def _resolve_neighbor_pdf(
|
|||||||
Mirrors validation._find_pdf's local-then-library lookup so neighbor
|
Mirrors validation._find_pdf's local-then-library lookup so neighbor
|
||||||
datasheets follow the same resolution rules as the IC under review.
|
datasheets follow the same resolution rules as the IC under review.
|
||||||
"""
|
"""
|
||||||
safe = safe_mpn(mpn)
|
from backend.services.datasheet_finder import find_local_pdf
|
||||||
local = state.pdf_dir / f"{safe}.pdf"
|
|
||||||
if local.is_file():
|
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
|
return local
|
||||||
if state.storage is not None:
|
if state.storage is not None:
|
||||||
try:
|
try:
|
||||||
from backend.services import projects as proj_svc
|
from backend.services import projects as proj_svc
|
||||||
lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
|
lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
|
||||||
if lib_key:
|
if lib_key:
|
||||||
state.storage.download_to_local(lib_key, local)
|
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||||
if local.is_file():
|
state.storage.download_to_local(lib_key, wanted)
|
||||||
return local
|
if wanted.is_file():
|
||||||
|
return wanted
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("excerpt: library lookup failed for %s", mpn)
|
log.exception("excerpt: library lookup failed for %s", mpn)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -138,6 +139,34 @@ def mpn_catalog_match(query: str, candidate: str) -> bool:
|
|||||||
return c.startswith(q) and len(c) > len(q)
|
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:
|
def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
|
||||||
exact: dict | None = None
|
exact: dict | None = None
|
||||||
loose: dict | None = None
|
loose: dict | None = None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -681,15 +681,16 @@ async def _ensure_local_datasheet(
|
|||||||
"""
|
"""
|
||||||
if pdf_path.is_file():
|
if pdf_path.is_file():
|
||||||
return True
|
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]:
|
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)
|
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, name)
|
||||||
if lib_ds_key:
|
if lib_ds_key:
|
||||||
ctx.storage.download_to_local(lib_ds_key, pdf_path)
|
ctx.storage.download_to_local(lib_ds_key, pdf_path)
|
||||||
@@ -1116,6 +1117,16 @@ async def _catalog_resolve_unresolved_passives(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
first_error = str(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:
|
if model is None:
|
||||||
bom_value = ctx.passive_values.get(mpn, "").strip()
|
bom_value = ctx.passive_values.get(mpn, "").strip()
|
||||||
refs = ctx.passive_mpns.get(mpn, [])
|
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_path.write_text(model.model_dump_json(indent=2) + "\n")
|
||||||
model_key = f"{ctx.ws.prefix}/models/{safe}.json"
|
model_key = f"{ctx.ws.prefix}/models/{safe}.json"
|
||||||
ctx.storage.upload_from_local(model_path, model_key)
|
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(
|
proj_svc.save_to_library(
|
||||||
ctx.storage, model_key, "passives", f"{safe}.json",
|
ctx.storage, model_key, "passives", f"{safe}.json",
|
||||||
)
|
)
|
||||||
@@ -1161,6 +1172,7 @@ async def _catalog_resolve_unresolved_passives(
|
|||||||
detail = {
|
detail = {
|
||||||
"lcsc": "auto-resolved via LCSC",
|
"lcsc": "auto-resolved via LCSC",
|
||||||
"digikey": "auto-resolved via DigiKey",
|
"digikey": "auto-resolved via DigiKey",
|
||||||
|
"mpn": "decoded from MPN (saved to library)",
|
||||||
"value": "resolved from BOM value (not saved to library)",
|
"value": "resolved from BOM value (not saved to library)",
|
||||||
}.get(resolved_via, "resolved")
|
}.get(resolved_via, "resolved")
|
||||||
broker.publish(ctx.project_id, "step_update",
|
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.
|
# 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.
|
# 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] = []
|
planned_refs: list[str] = []
|
||||||
for ref, comp in ctx.graph.components.items():
|
for ref, comp in ctx.graph.components.items():
|
||||||
if comp.component_type != ComponentType.IC:
|
if comp.component_type != ComponentType.IC:
|
||||||
continue
|
continue
|
||||||
mpn = comp.mpn or comp.value
|
mpn = (comp.mpn or "").strip() or (comp.value or "").strip()
|
||||||
if not mpn:
|
if not mpn:
|
||||||
continue
|
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)
|
planned_refs.append(ref)
|
||||||
ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key)
|
ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key)
|
||||||
|
|
||||||
|
|||||||
@@ -775,6 +775,31 @@ def save_to_library(
|
|||||||
return dst_key
|
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:
|
def list_library_catalog(storage: StorageBackend) -> dict:
|
||||||
"""List ICs, passive patterns, discrete specs, and datasheet refs.
|
"""List ICs, passive patterns, discrete specs, and datasheet refs.
|
||||||
|
|
||||||
@@ -826,26 +851,24 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
simple_models: list[dict] = []
|
simple_models: list[dict] = []
|
||||||
|
passive_parts: list[dict] = []
|
||||||
seen_model_mpns: set[str] = set()
|
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):
|
for key in storage.list_prefix(prefix):
|
||||||
if not key.endswith(".json"):
|
if not key.endswith(".json"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
data = storage.read_json(key)
|
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:
|
if mpn in seen_model_mpns:
|
||||||
continue
|
continue
|
||||||
seen_model_mpns.add(mpn)
|
seen_model_mpns.add(mpn)
|
||||||
specs = data.get("specs", {}) or {}
|
row["has_datasheet"] = bool(resolve_datasheet(storage, mpn))
|
||||||
simple_models.append({
|
dest.append(row)
|
||||||
"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)),
|
|
||||||
})
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -871,11 +894,13 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
|||||||
|
|
||||||
ics.sort(key=lambda r: r["mpn"].lower())
|
ics.sort(key=lambda r: r["mpn"].lower())
|
||||||
passives.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())
|
simple_models.sort(key=lambda r: r["mpn"].lower())
|
||||||
datasheets.sort(key=lambda r: r["mpn"].lower())
|
datasheets.sort(key=lambda r: r["mpn"].lower())
|
||||||
return {
|
return {
|
||||||
"ics": ics,
|
"ics": ics,
|
||||||
"passives": passives,
|
"passives": passives,
|
||||||
|
"passive_parts": passive_parts,
|
||||||
"simple": simple_models,
|
"simple": simple_models,
|
||||||
"datasheets": datasheets,
|
"datasheets": datasheets,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,27 +571,29 @@ def _find_pdf(
|
|||||||
"""Find the datasheet PDF for an MPN. Checks local dir first,
|
"""Find the datasheet PDF for an MPN. Checks local dir first,
|
||||||
then tries to download from the library.
|
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
|
from backend.pinscopex.utils import safe_mpn as _safe
|
||||||
|
|
||||||
names = mpn_query_variants(mpn) or [mpn]
|
mpn = (mpn or "").strip()
|
||||||
for name in names:
|
if not mpn:
|
||||||
local = pdf_dir / f"{_safe(name)}.pdf"
|
return None
|
||||||
if local.is_file():
|
|
||||||
wanted = pdf_dir / f"{_safe(mpn)}.pdf"
|
local = find_local_pdf(pdf_dir, mpn)
|
||||||
if local != wanted and not wanted.is_file():
|
if local is not None and local.is_file():
|
||||||
wanted.write_bytes(local.read_bytes())
|
wanted = pdf_dir / f"{_safe(mpn)}.pdf"
|
||||||
return wanted
|
if local.resolve() != wanted.resolve() and not wanted.is_file():
|
||||||
return local
|
wanted.write_bytes(local.read_bytes())
|
||||||
|
return wanted
|
||||||
|
return local
|
||||||
|
|
||||||
if storage:
|
if storage:
|
||||||
from backend.services import projects as proj_svc
|
from backend.services import projects as proj_svc
|
||||||
lib_key = proj_svc.library_has_datasheet(storage, mpn)
|
lib_key = proj_svc.library_has_datasheet(storage, mpn)
|
||||||
if lib_key:
|
if lib_key:
|
||||||
local = pdf_dir / f"{_safe(mpn)}.pdf"
|
wanted = pdf_dir / f"{_safe(mpn)}.pdf"
|
||||||
storage.download_to_local(lib_key, local)
|
storage.download_to_local(lib_key, wanted)
|
||||||
if local.is_file():
|
if wanted.is_file():
|
||||||
return local
|
return wanted
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -665,7 +667,12 @@ async def validate_design_async(
|
|||||||
for ref, comp in sorted(graph.components.items()):
|
for ref, comp in sorted(graph.components.items()):
|
||||||
if comp.component_type != ComponentType.IC:
|
if comp.component_type != ComponentType.IC:
|
||||||
continue
|
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)
|
pdf = _find_pdf(mpn, pdf_dir_path, storage=storage)
|
||||||
if pdf:
|
if pdf:
|
||||||
ic_tasks.append((ref, str(pdf)))
|
ic_tasks.append((ref, str(pdf)))
|
||||||
|
|||||||
@@ -632,7 +632,11 @@ function ComponentsPanel() {
|
|||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) };
|
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) };
|
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();
|
reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -678,6 +682,12 @@ function ComponentsPanel() {
|
|||||||
c.subtype.toLowerCase().includes(lf) ||
|
c.subtype.toLowerCase().includes(lf) ||
|
||||||
c.description.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(
|
const filteredSimple = (data.simple ?? []).filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.mpn.toLowerCase().includes(lf) ||
|
c.mpn.toLowerCase().includes(lf) ||
|
||||||
@@ -699,6 +709,13 @@ function ComponentsPanel() {
|
|||||||
<span className="font-medium">{data.passives.length}</span>
|
<span className="font-medium">{data.passives.length}</span>
|
||||||
<span className="text-muted-foreground">Passive Patterns</span>
|
<span className="text-muted-foreground">Passive Patterns</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(data.passive_parts?.length ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="font-medium">{data.passive_parts.length}</span>
|
||||||
|
<span className="text-muted-foreground">Passive MPNs</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(data.simple?.length ?? 0) > 0 && (
|
{(data.simple?.length ?? 0) > 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-sm">
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
@@ -827,6 +844,57 @@ function ComponentsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Per-MPN passive specs */}
|
||||||
|
{filteredPassiveParts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium mb-2">Passive MPNs</h2>
|
||||||
|
<div className="rounded-lg border border-border overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-muted-foreground">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">MPN</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Type</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">Subtype</th>
|
||||||
|
<th className="text-center px-3 py-2 font-medium">Params</th>
|
||||||
|
<th className="w-10 px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{filteredPassiveParts.map((s) => (
|
||||||
|
<tr key={s.mpn} className="hover:bg-muted/30 cursor-pointer" onClick={() => handleRowClick("simple", s.mpn)}>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{s.mpn}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="secondary">{s.specs_type || "passive"}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{s.subtype ? (
|
||||||
|
<Badge variant="outline">{s.subtype}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center font-mono">{s.param_count}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDelete("simple", s.mpn); }}
|
||||||
|
disabled={deleting === s.mpn}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting === s.mpn ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Simple component specs table */}
|
{/* Simple component specs table */}
|
||||||
{filteredSimple.length > 0 && (
|
{filteredSimple.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@@ -878,7 +946,7 @@ function ComponentsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredICs.length === 0 && filteredPassives.length === 0 && filteredSimple.length === 0 && (
|
{filteredICs.length === 0 && filteredPassives.length === 0 && filteredPassiveParts.length === 0 && filteredSimple.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
<Package className="h-8 w-8 mb-2" />
|
<Package className="h-8 w-8 mb-2" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ export default function LibraryPage() {
|
|||||||
),
|
),
|
||||||
[data, lf],
|
[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(
|
const simple = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(data?.simple ?? []).filter(
|
(data?.simple ?? []).filter(
|
||||||
@@ -108,6 +119,7 @@ export default function LibraryPage() {
|
|||||||
!data ||
|
!data ||
|
||||||
(data.ics.length === 0 &&
|
(data.ics.length === 0 &&
|
||||||
data.passives.length === 0 &&
|
data.passives.length === 0 &&
|
||||||
|
(data.passive_parts ?? []).length === 0 &&
|
||||||
data.simple.length === 0 &&
|
data.simple.length === 0 &&
|
||||||
data.datasheets.length === 0);
|
data.datasheets.length === 0);
|
||||||
|
|
||||||
@@ -143,8 +155,15 @@ export default function LibraryPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{(data.passive_parts ?? []).length}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">passives</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Zap className="h-4 w-4 text-amber-600/70 dark:text-amber-400/70" />
|
||||||
<span className="font-medium">{data.passives.length}</span>
|
<span className="font-medium">{data.passives.length}</span>
|
||||||
<span className="text-muted-foreground">passive series</span>
|
<span className="text-muted-foreground">series</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
@@ -169,7 +188,7 @@ export default function LibraryPage() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="ics">ICs ({ics.length})</TabsTrigger>
|
<TabsTrigger value="ics">ICs ({ics.length})</TabsTrigger>
|
||||||
<TabsTrigger value="passives">
|
<TabsTrigger value="passives">
|
||||||
Passives ({passives.length})
|
Passives ({passiveParts.length + passives.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="simple">
|
<TabsTrigger value="simple">
|
||||||
Discrete ({simple.length})
|
Discrete ({simple.length})
|
||||||
@@ -225,40 +244,109 @@ export default function LibraryPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="passives" className="pt-4">
|
<TabsContent value="passives" className="pt-4 space-y-6">
|
||||||
{passives.length === 0 ? (
|
{passiveParts.length === 0 && passives.length === 0 ? (
|
||||||
<EmptyFilter label="passive series" />
|
<EmptyFilter label="passives" />
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-border overflow-x-auto">
|
<>
|
||||||
<table className="w-full text-sm">
|
{passiveParts.length > 0 && (
|
||||||
<thead>
|
<div className="rounded-lg border border-border overflow-x-auto">
|
||||||
<tr className="bg-muted/50 text-muted-foreground">
|
<table className="w-full text-sm">
|
||||||
<th className="text-left px-3 py-2 font-medium">Series</th>
|
<thead>
|
||||||
<th className="text-left px-3 py-2 font-medium">Type</th>
|
<tr className="bg-muted/50 text-muted-foreground">
|
||||||
<th className="text-left px-3 py-2 font-medium">
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
Description
|
MPN
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
</thead>
|
Type
|
||||||
<tbody className="divide-y divide-border">
|
</th>
|
||||||
{passives.map((p) => (
|
<th className="text-center px-3 py-2 font-medium">
|
||||||
<tr key={p.mpn} className="hover:bg-muted/30">
|
Params
|
||||||
<td className="px-3 py-2 font-mono text-xs">{p.mpn}</td>
|
</th>
|
||||||
<td className="px-3 py-2">
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
{p.subtype ? (
|
PDF
|
||||||
<Badge variant="secondary">{p.subtype}</Badge>
|
</th>
|
||||||
) : (
|
</tr>
|
||||||
<span className="text-muted-foreground">—</span>
|
</thead>
|
||||||
)}
|
<tbody className="divide-y divide-border">
|
||||||
</td>
|
{passiveParts.map((p) => (
|
||||||
<td className="px-3 py-2 text-muted-foreground">
|
<tr key={p.mpn} className="hover:bg-muted/30">
|
||||||
{p.description || "—"}
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
</td>
|
{p.mpn}
|
||||||
</tr>
|
</td>
|
||||||
))}
|
<td className="px-3 py-2">
|
||||||
</tbody>
|
<Badge variant="secondary">
|
||||||
</table>
|
{p.subtype || p.specs_type || "passive"}
|
||||||
</div>
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{p.param_count}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{p.has_datasheet ? (
|
||||||
|
<DatasheetLink
|
||||||
|
mpn={p.mpn}
|
||||||
|
opening={opening}
|
||||||
|
onOpen={openDatasheet}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{passives.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Series patterns — one regex covers cousin MPNs
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-border overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50 text-muted-foreground">
|
||||||
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
|
Series
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium">
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{passives.map((p) => (
|
||||||
|
<tr key={p.mpn} className="hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{p.mpn}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{p.subtype ? (
|
||||||
|
<Badge variant="secondary">{p.subtype}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">
|
||||||
|
{p.description || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,15 @@ export interface LibraryPassive {
|
|||||||
regex: string;
|
regex: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryPassivePart {
|
||||||
|
mpn: string;
|
||||||
|
type: "passive_part";
|
||||||
|
specs_type: string;
|
||||||
|
subtype: string;
|
||||||
|
param_count: number;
|
||||||
|
has_datasheet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LibrarySimple {
|
export interface LibrarySimple {
|
||||||
mpn: string;
|
mpn: string;
|
||||||
type: "simple";
|
type: "simple";
|
||||||
@@ -408,6 +417,7 @@ export interface LibraryDatasheet {
|
|||||||
export interface LibraryCatalog {
|
export interface LibraryCatalog {
|
||||||
ics: LibraryIC[];
|
ics: LibraryIC[];
|
||||||
passives: LibraryPassive[];
|
passives: LibraryPassive[];
|
||||||
|
passive_parts?: LibraryPassivePart[];
|
||||||
simple: LibrarySimple[];
|
simple: LibrarySimple[];
|
||||||
datasheets: LibraryDatasheet[];
|
datasheets: LibraryDatasheet[];
|
||||||
}
|
}
|
||||||
@@ -606,6 +616,7 @@ export interface AdminSimple {
|
|||||||
export interface AdminComponents {
|
export interface AdminComponents {
|
||||||
ics: AdminIC[];
|
ics: AdminIC[];
|
||||||
passives: AdminPassive[];
|
passives: AdminPassive[];
|
||||||
|
passive_parts?: AdminSimple[];
|
||||||
simple: AdminSimple[];
|
simple: AdminSimple[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]["pin_count"] == 2
|
||||||
assert cat["ics"][0]["has_datasheet"] is True
|
assert cat["ics"][0]["has_datasheet"] is True
|
||||||
assert cat["passives"][0]["mpn"] == "Samsung CL10"
|
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"])
|
assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user