Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam. Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI. Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""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"},
|
||||
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 client.get("/api/projects").json() == before
|
||||
catalog = client.get("/api/library").json()
|
||||
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
||||
pdf = client.get("/api/library/datasheet/CH340E")
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.content.startswith(b"%PDF-")
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for shortest_path, library_gate, cache_stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.library_gate import pintable_checksum, should_promote_extraction
|
||||
from backend.periscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
|
||||
from backend.periscopex.review_tools import shortest_path
|
||||
from backend.services.api_logs import cache_stats_by_stage
|
||||
|
||||
|
||||
def _graph_r_chain() -> DesignGraph:
|
||||
"""U1.1 — NET_A — R1 — NET_B — U2.2"""
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="",
|
||||
component_type=ComponentType.IC, pins={"1": "NET_A"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "NET_A", "2": "NET_B"},
|
||||
),
|
||||
"U2": Component(
|
||||
reference="U2", value="IC", footprint="",
|
||||
component_type=ComponentType.IC, pins={"2": "NET_B"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"NET_A": Net(
|
||||
name="NET_A", net_type=NetType.SIGNAL,
|
||||
pins=[
|
||||
PinConnection(component_ref="U1", pin_number="1"),
|
||||
PinConnection(component_ref="R1", pin_number="1"),
|
||||
],
|
||||
),
|
||||
"NET_B": Net(
|
||||
name="NET_B", net_type=NetType.SIGNAL,
|
||||
pins=[
|
||||
PinConnection(component_ref="R1", pin_number="2"),
|
||||
PinConnection(component_ref="U2", pin_number="2"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_shortest_path_two_hops():
|
||||
g = _graph_r_chain()
|
||||
msg = shortest_path(g, {}, "U1", "1", "U2", "2")
|
||||
assert "hop" in msg.lower()
|
||||
assert "NET_A" in msg and "NET_B" in msg
|
||||
assert "U1.1" in msg and "U2.2" in msg
|
||||
|
||||
|
||||
def test_shortest_path_same_net():
|
||||
g = _graph_r_chain()
|
||||
msg = shortest_path(g, {}, "U1", "1", "R1", "1")
|
||||
assert "same net" in msg.lower() or "Direct" in msg
|
||||
|
||||
|
||||
def test_library_gate_rejects_empty():
|
||||
ok, reason = should_promote_extraction({"pintable": []})
|
||||
assert not ok
|
||||
assert "empty" in reason
|
||||
|
||||
|
||||
def test_library_gate_accepts_named_pins():
|
||||
data = {
|
||||
"pintable": [
|
||||
{"number": "1", "name": "VDD"},
|
||||
{"number": "2", "name": "GND"},
|
||||
],
|
||||
}
|
||||
ok, checksum = should_promote_extraction(data)
|
||||
assert ok
|
||||
assert checksum == pintable_checksum(data["pintable"])
|
||||
|
||||
|
||||
def test_cache_stats_by_stage():
|
||||
entries = [
|
||||
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 40},
|
||||
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 60},
|
||||
{"stage": "validation", "input_tokens": 50, "cache_read_input_tokens": 0},
|
||||
]
|
||||
stats = cache_stats_by_stage(entries)
|
||||
assert stats["pintable"]["calls"] == 2
|
||||
assert stats["pintable"]["hit_ratio"] == 0.5
|
||||
assert stats["validation"]["hit_ratio"] == 0.0
|
||||
Reference in New Issue
Block a user