Files
periscope/tests/test_kicad_project.py
T
michele fbf2fc87a6 Detect HubAudio.kicad_pro from folder pick, not only File.name.
Safari/macOS often puts .kicad_pro on webkitRelativePath while name is
the stem. Match both, sniff JSON if the suffix is missing, and set
webkitdirectory on the input DOM so the folder picker actually recurses.
2026-09-21 07:54:35 +02:00

324 lines
11 KiB
Python

"""KiCad project folder ingest: *.kicad_pro + siblings, no netlist."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from backend.periscopex.kicad_project import load_kicad_project, sniff_kicad_pro
from backend.periscopex.netlist_bundle import materialize_netlist_upload, sniff_netlist_kind
from backend.periscopex.parsers import parse_netlist_any
HUBAUDIO = Path("/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio")
_LIB_R = """
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
(symbol "Device:U"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
)
)
"""
def _sch(*body: str) -> str:
return (
"(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")"
+ _LIB_R
+ "".join(body)
+ "\n)\n"
)
def _resistor(ref: str, value: str) -> str:
uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:R")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1"))
(pin "2" (uuid "p2"))
)
"""
def _ic(ref: str, value: str, pnm: str) -> str:
uid = "bbbbbbbb-bbbb-bbbb-bbbb-" + ref.encode().hex()[:12].ljust(12, "0")
return f"""
(symbol
(lib_id "Device:U")
(at 0 0 0)
(unit 1)
(uuid "{uid}")
(property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "PNM" "{pnm}" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "u1"))
)
"""
def _pro(root_name: str) -> str:
return json.dumps({
"meta": {"filename": root_name.replace(".kicad_sch", ".kicad_pro")},
"schematic": {
"top_level_sheets": [{"filename": root_name, "name": "root"}],
},
})
def _write_project(folder: Path) -> None:
child = _sch(
_ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
)
folder.mkdir(parents=True, exist_ok=True)
(folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch"))
(folder / "HubAudio.kicad_sch").write_text(root)
(folder / "Codec.kicad_sch").write_text(child)
(folder / "HubAudio.kicad_pcb").write_text(
'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n'
)
(folder / "netlist.asc").write_text(
"*PADS-PCB*\n*PART*\nU13 SOT23\nR1 0603\n*NET*\n"
"*SIGNAL* GND\nU13.1 R1.1\n*END*\n"
)
def test_sniff_kicad_pro_json():
body = _pro("HubAudio.kicad_sch").encode()
assert sniff_kicad_pro(body, "HubAudio.kicad_pro")
assert sniff_netlist_kind(body, "HubAudio.kicad_pro") == "kicad_pro"
def test_lone_kicad_pro_bytes_are_rejected(tmp_path: Path):
with pytest.raises(ValueError, match="da solo non basta"):
materialize_netlist_upload(
[("HubAudio.kicad_pro", _pro("HubAudio.kicad_sch").encode())],
tmp_path / "work",
)
def test_folder_next_to_pro_loads_sheets_and_pcb(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
files = [
(str(p.relative_to(tmp_path)), p.read_bytes())
for p in src.iterdir()
if p.is_file()
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert parsed.pcb is not None and parsed.pcb.name == "HubAudio.kicad_pcb"
assert "R1" in parts and "U9" in parts
assert "U13" not in parts
assert {p.name for p in parsed.extra_sch} == {"Codec.kicad_sch"}
def test_stale_pads_asc_ignored_when_pro_present(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
loaded = load_kicad_project(src)
assert loaded.root_sch.name == "HubAudio.kicad_sch"
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U13" not in parts
def test_disk_path_to_pro_copies_siblings(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
parsed = materialize_netlist_upload(
[(str(src / "HubAudio.kicad_pro"), (src / "HubAudio.kicad_pro").read_bytes())],
tmp_path / "work",
)
parts, _nets, _fmt = parse_netlist_any(parsed.root)
assert "U9" in parts and "U13" not in parts
assert parsed.pcb is not None
def test_graph_without_bom_csv_uses_schematic_pnm(tmp_path: Path):
from backend.periscopex.graph import build_graph
src = tmp_path / "HubAudio"
_write_project(src)
missing = tmp_path / "no-bom.csv"
g = build_graph(
src / "HubAudio.kicad_sch",
missing,
tmp_path / "ex",
tmp_path / "pat",
tmp_path / "mod",
)
assert g.components["U9"].mpn == "TPD2E007DCKR"
assert "U13" not in g.components
def test_empty_child_pnm_not_invented(tmp_path: Path):
from backend.periscopex.graph import build_graph
folder = tmp_path / "proj"
folder.mkdir()
child = _sch(
_ic("U15", "", ""),
"""
(global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc"))
""",
)
root = _sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Codec" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "Codec.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
)
(folder / "p.kicad_pro").write_text(_pro("p.kicad_sch"))
(folder / "p.kicad_sch").write_text(root)
(folder / "Codec.kicad_sch").write_text(child)
g = build_graph(
folder / "p.kicad_sch",
tmp_path / "missing.csv",
tmp_path / "ex",
tmp_path / "pat",
tmp_path / "mod",
)
assert not (g.components["U15"].mpn or "").strip()
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
def test_hubaudio_folder_siblings_only():
loaded = load_kicad_project(HUBAUDIO)
assert loaded.pro.name == "HubAudio.kicad_pro"
assert loaded.root_sch.name == "HubAudio.kicad_sch"
names = {p.name for p in loaded.sheets}
assert "Codec.kicad_sch" in names
assert "POWER.kicad_sch" in names
assert loaded.pcb is not None and loaded.pcb.name == "HubAudio.kicad_pcb"
assert loaded.folder == HUBAUDIO.resolve()
parts, _nets, fmt = parse_netlist_any(loaded.root_sch)
assert fmt == "kicad_sch"
assert "U13" not in parts
assert "U9" in parts
def test_api_folder_upload_no_csv(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "ha"}).json()["id"]
src = tmp_path / "src"
_write_project(src)
files = [
("files", (p.name, p.read_bytes(), "application/octet-stream"))
for p in src.iterdir()
if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}
]
paths = [p.name for p in src.iterdir() if p.suffix in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}]
resp = client.post(
f"/api/projects/{pid}/upload/netlist",
files=files,
data={"paths": json.dumps(paths)},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["format"] == "kicad_sch"
assert body["pcb_saved"] is True
assert body["bom_saved"] is True
assert "U13" not in str(body)
assert body["parts"] >= 2
def _looks_like_kicad_pro(name: str, relative: str = "") -> bool:
"""Same rule as frontend looksLikeKicadPro (name and/or webkitRelativePath)."""
return any(".kicad_pro" in s.lower() for s in (name, relative, Path(relative or name).name))
def test_safari_stem_name_is_still_hubaudio_kicad_pro():
"""Live 2.60.6 checked only File.name.endsWith('.kicad_pro') and missed Safari."""
assert not "HubAudio".lower().endswith(".kicad_pro")
assert _looks_like_kicad_pro("HubAudio", "HubAudio/HubAudio.kicad_pro")
assert _looks_like_kicad_pro("HubAudio.kicad_pro", "")
assert sniff_netlist_kind(
_pro("HubAudio.kicad_sch").encode(), "HubAudio",
) == "kicad_pro"
def test_safari_stem_upload_writes_kicad_pro_suffix(tmp_path: Path):
src = tmp_path / "HubAudio"
_write_project(src)
pro = (src / "HubAudio.kicad_pro").read_bytes()
files = [
("HubAudio", pro),
("HubAudio.kicad_sch", (src / "HubAudio.kicad_sch").read_bytes()),
("Codec.kicad_sch", (src / "Codec.kicad_sch").read_bytes()),
("HubAudio.kicad_pcb", (src / "HubAudio.kicad_pcb").read_bytes()),
]
parsed = materialize_netlist_upload(files, tmp_path / "work")
assert parsed.root.name == "HubAudio.kicad_sch"
assert any(p.name == "HubAudio.kicad_pro" for p in (tmp_path / "work").rglob("*"))
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "U9" in parts and "U13" not in parts
@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk")
def test_real_hubaudio_kicad_pro_filename_and_folder_ingest(tmp_path: Path):
pro_path = HUBAUDIO / "HubAudio.kicad_pro"
assert pro_path.name == "HubAudio.kicad_pro"
data = pro_path.read_bytes()
assert sniff_kicad_pro(data, "HubAudio.kicad_pro")
assert sniff_netlist_kind(data, "HubAudio") == "kicad_pro"
files: list[tuple[str, bytes]] = []
for path in HUBAUDIO.iterdir():
if path.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}:
files.append((f"HubAudio/{path.name}", path.read_bytes()))
assert any(name.endswith("HubAudio.kicad_pro") for name, _ in files)
parsed = materialize_netlist_upload(files, tmp_path / "work")
assert parsed.root.name == "HubAudio.kicad_sch"
assert parsed.pcb is not None
parts, _nets, fmt = parse_netlist_any(parsed.root)
assert fmt == "kicad_sch"
assert "U13" not in parts