POST /api/library/datasheets now writes an IC inbox card or a passive/discrete model. Empty pintables never enter library/extracted. AF Board+AI runs after unchanged run_pcb_checks (PE-SI / HF line stay).
287 lines
9.9 KiB
Python
287 lines
9.9 KiB
Python
"""Shared component library: persist datasheets, catalog listing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from backend.services import projects as proj_svc
|
|
from backend.services.datasheet_store import resolve_datasheet
|
|
from backend.services.storage import LocalStorageBackend
|
|
|
|
|
|
PDF = b"%PDF-1.4\n" + b"x" * 8000
|
|
|
|
|
|
def _client(tmp_path) -> TestClient:
|
|
from backend.main import app
|
|
|
|
app.state.storage = LocalStorageBackend(tmp_path)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_library_alias_resolves_family_mpn(storage):
|
|
from backend.services.datasheet_store import resolve_datasheet, store_datasheet_bytes
|
|
|
|
store_datasheet_bytes(
|
|
storage, PDF, "ESP32-S31-WROOM-3",
|
|
extra_mpns=["ESP32-S31-WROOM-3-N16R16V"],
|
|
)
|
|
assert resolve_datasheet(storage, "ESP32-S31-WROOM-3")
|
|
assert resolve_datasheet(storage, "ESP32-S31-WROOM-3-N16R16V")
|
|
assert proj_svc.library_has_datasheet(storage, "ESP32-S31-WROOM-3")
|
|
|
|
|
|
def test_save_datasheet_also_stores_in_library(storage):
|
|
meta = proj_svc.create_project(storage, "local", "board")
|
|
key = proj_svc.save_datasheet(storage, "local", meta.id, "CH340E", PDF)
|
|
assert key.endswith("CH340E.pdf")
|
|
assert storage.exists(key)
|
|
assert resolve_datasheet(storage, "CH340E")
|
|
assert proj_svc.library_has_datasheet(storage, "CH340E")
|
|
|
|
|
|
def test_library_catalog_lists_ics_passives_and_pdfs(storage):
|
|
storage.write_json(
|
|
"library/extracted/CH340E.json",
|
|
{
|
|
"mpn": "CH340E",
|
|
"pintable": [{"pin": 1}, {"pin": 2}],
|
|
"component_subtype": "usb-uart",
|
|
"absolute_maximum_ratings": {"vcc": "5V"},
|
|
},
|
|
)
|
|
storage.write_json(
|
|
"library/patterns/samsung_c.json",
|
|
{
|
|
"name": "Samsung CL10",
|
|
"component_type": "capacitor",
|
|
"description": "Samsung 0603 MLCC",
|
|
"regex": r"^CL10",
|
|
},
|
|
)
|
|
storage.write_json(
|
|
"library/passives/CL10B474KA8NNNC.json",
|
|
{
|
|
"mpn": "CL10B474KA8NNNC",
|
|
"specs": {
|
|
"specs_type": "capacitor",
|
|
"component_subtype": "mlcc",
|
|
"values": {"capacitance": "470n"},
|
|
},
|
|
},
|
|
)
|
|
proj_svc.remember_datasheet(storage, "CH340E", PDF)
|
|
|
|
cat = proj_svc.list_library_catalog(storage)
|
|
assert len(cat["ics"]) == 1
|
|
assert cat["ics"][0]["mpn"] == "CH340E"
|
|
assert cat["ics"][0]["pin_count"] == 2
|
|
assert cat["ics"][0]["has_datasheet"] is True
|
|
assert cat["passives"][0]["mpn"] == "Samsung CL10"
|
|
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"])
|
|
|
|
|
|
def test_library_http_catalog_and_pdf(tmp_path):
|
|
client = _client(tmp_path)
|
|
empty = client.get("/api/library")
|
|
assert empty.status_code == 200
|
|
body = empty.json()
|
|
assert body["ics"] == []
|
|
assert body["datasheets"] == []
|
|
|
|
meta = client.post("/api/projects", json={"name": "lib"}).json()
|
|
resp = client.post(
|
|
f"/api/projects/{meta['id']}/upload/datasheets",
|
|
params={"mpn": "MSPM0G3507"},
|
|
files={"file": ("msp.pdf", PDF, "application/pdf")},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
catalog = client.get("/api/library").json()
|
|
assert any(d["mpn"] == "MSPM0G3507" for d in catalog["datasheets"])
|
|
|
|
pdf = client.get("/api/library/datasheet/MSPM0G3507")
|
|
assert pdf.status_code == 200
|
|
assert pdf.content.startswith(b"%PDF-")
|
|
|
|
|
|
def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
|
|
from backend.services.datasheet_finder import DatasheetHit
|
|
|
|
async def fake_find(mpn, lcsc_id=None):
|
|
return DatasheetHit(
|
|
mpn,
|
|
pdf_bytes=PDF,
|
|
url="https://datasheet.lcsc.com/x.pdf",
|
|
source="lcsc",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"backend.services.datasheet_finder.find_datasheet", fake_find,
|
|
)
|
|
client = _client(tmp_path)
|
|
resp = client.get("/api/datasheets/fetch", params={"mpn": "CH340E"})
|
|
assert resp.status_code == 200
|
|
assert resp.content.startswith(b"%PDF-")
|
|
|
|
catalog = client.get("/api/library").json()
|
|
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
|
assert client.get("/api/library/datasheet/CH340E").status_code == 200
|
|
|
|
|
|
def test_library_import_pdf_without_project(tmp_path):
|
|
client = _client(tmp_path)
|
|
before = client.get("/api/projects").json()
|
|
resp = client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": "CH340E", "kind": "ic"},
|
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["mpn"] == "CH340E"
|
|
assert body["has_extraction"] is False
|
|
assert body["component_type"] == "ic"
|
|
assert body["status"] == "needs_pintable"
|
|
assert client.get("/api/projects").json() == before
|
|
catalog = client.get("/api/library").json()
|
|
assert any(d["mpn"] == "CH340E" and d.get("has_component") for d in catalog["datasheets"])
|
|
ics = [c for c in catalog["ics"] if c["mpn"] == "CH340E"]
|
|
assert len(ics) == 1
|
|
assert ics[0]["pin_count"] == 0
|
|
assert ics[0]["library_status"] == "needs_pintable"
|
|
pdf = client.get("/api/library/datasheet/CH340E")
|
|
assert pdf.status_code == 200
|
|
assert pdf.content.startswith(b"%PDF-")
|
|
card = client.get("/api/library/components/ic/CH340E")
|
|
assert card.status_code == 200
|
|
assert card.json()["status"] == "needs_pintable"
|
|
from backend.main import app
|
|
from backend.services.library import library_has_extraction
|
|
|
|
assert library_has_extraction(app.state.storage, "CH340E") is None
|
|
assert not app.state.storage.exists("library/extracted/CH340E.json")
|
|
|
|
|
|
def test_library_import_rejects_non_pdf(tmp_path):
|
|
client = _client(tmp_path)
|
|
resp = client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": "CH340E"},
|
|
files={"file": ("notes.txt", b"not a pdf", "text/plain")},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert client.get("/api/library").json()["datasheets"] == []
|
|
|
|
|
|
def test_library_import_rejects_blank_mpn(tmp_path):
|
|
client = _client(tmp_path)
|
|
resp = client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": " "},
|
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
|
)
|
|
assert resp.status_code in (400, 422)
|
|
|
|
|
|
def test_library_get_put_component(tmp_path):
|
|
client = _client(tmp_path)
|
|
from backend.main import app
|
|
|
|
storage = app.state.storage
|
|
storage.write_json(
|
|
"library/extracted/CH340E.json",
|
|
{
|
|
"mpn": "CH340E",
|
|
"component_subtype": "ic.interface.usb_uart_bridge",
|
|
"pintable": [
|
|
{"number": "1", "name": "VCC"},
|
|
{"number": "2", "name": "GND"},
|
|
],
|
|
},
|
|
)
|
|
got = client.get("/api/library/components/ic/CH340E")
|
|
assert got.status_code == 200
|
|
assert got.json()["component_subtype"] == "ic.interface.usb_uart_bridge"
|
|
|
|
payload = got.json()
|
|
payload["component_subtype"] = "ic.interface.usb_uart"
|
|
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
|
assert saved.status_code == 200
|
|
assert saved.json()["component_subtype"] == "ic.interface.usb_uart"
|
|
again = client.get("/api/library/components/ic/CH340E").json()
|
|
assert again["component_subtype"] == "ic.interface.usb_uart"
|
|
|
|
|
|
def test_library_put_rejects_empty_pintable(tmp_path):
|
|
client = _client(tmp_path)
|
|
resp = client.put(
|
|
"/api/library/components/ic/FAKEIC",
|
|
json={"mpn": "FAKEIC", "pintable": []},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
|
|
|
|
|
def test_library_import_ic_put_pintable_promotes_out_of_inbox(tmp_path):
|
|
client = _client(tmp_path)
|
|
assert client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": "CH340E", "kind": "ic"},
|
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
|
).status_code == 200
|
|
payload = {
|
|
"mpn": "CH340E",
|
|
"component_subtype": "ic.interface.usb_uart_bridge",
|
|
"pintable": [
|
|
{"number": "1", "name": "VCC"},
|
|
{"number": "2", "name": "GND"},
|
|
],
|
|
"absolute_maximum_ratings": [],
|
|
"rules": [],
|
|
}
|
|
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
|
assert saved.status_code == 200
|
|
from backend.main import app
|
|
|
|
assert app.state.storage.exists("library/extracted/CH340E.json")
|
|
assert not app.state.storage.exists("library/inbox/CH340E.json")
|
|
extracted = app.state.storage.read_json("library/extracted/CH340E.json")
|
|
assert "status" not in extracted
|
|
assert extracted["pintable"][0]["name"] == "VCC"
|
|
cat = client.get("/api/library").json()
|
|
ic = next(c for c in cat["ics"] if c["mpn"] == "CH340E")
|
|
assert ic["pin_count"] == 2
|
|
assert ic["library_status"] == "extracted"
|
|
|
|
|
|
def test_library_import_passive_without_exam(tmp_path):
|
|
client = _client(tmp_path)
|
|
resp = client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": "CL10B474KA8NNNC", "kind": "passive_part"},
|
|
files={"file": ("c.pdf", PDF, "application/pdf")},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["component_type"] == "passive_part"
|
|
assert client.get("/api/projects").json() == []
|
|
cat = client.get("/api/library").json()
|
|
assert any(p["mpn"] == "CL10B474KA8NNNC" for p in cat["passive_parts"])
|
|
got = client.get("/api/library/components/passive_part/CL10B474KA8NNNC")
|
|
assert got.status_code == 200
|
|
assert got.json()["specs"]["specs_type"] == "passive"
|
|
|
|
|
|
def test_library_import_rejects_unknown_kind(tmp_path):
|
|
client = _client(tmp_path)
|
|
resp = client.post(
|
|
"/api/library/datasheets",
|
|
data={"mpn": "CH340E", "kind": "project"},
|
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert client.get("/api/library").json()["ics"] == []
|