From a56dda2358204951c83f54fba9afb65f9b3be969 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 11 Sep 2026 07:26:27 +0200 Subject: [PATCH] Accept a KiCad project drop so hierarchical sheets and the board upload in one step. A single .kicad_sch was parsed in isolation, so child sheets looked like an empty netlist. Report 404s now surface the real API error instead of a generic fetch failure. Co-authored-by: Cursor --- backend/pinscopex/netlist_bundle.py | 233 ++++++++++++++++++ backend/pinscopex/parsers_kicad.py | 5 +- backend/routers/projects.py | 102 +++++--- backend/services/projects.py | 29 +++ frontend/content/changelog.md | 8 + frontend/content/file-guide.md | 7 +- frontend/package.json | 2 +- frontend/src/app/(app)/project/[id]/page.tsx | 25 +- .../app/(app)/project/[id]/progress/page.tsx | 26 +- .../app/(app)/project/[id]/report/page.tsx | 14 +- .../dashboard/create-project-dialog.tsx | 149 ++++++++--- .../components/project/impedance-panel.tsx | 14 +- .../src/components/project/pcb-upload.tsx | 60 +++++ .../components/upload/file-upload-zone.tsx | 113 ++++++++- frontend/src/hooks/use-report.ts | 46 +++- frontend/src/lib/api.ts | 44 +++- frontend/src/lib/version.ts | 2 +- tests/test_netlist_bundle.py | 202 +++++++++++++++ 18 files changed, 965 insertions(+), 116 deletions(-) create mode 100644 backend/pinscopex/netlist_bundle.py create mode 100644 frontend/src/components/project/pcb-upload.tsx create mode 100644 tests/test_netlist_bundle.py diff --git a/backend/pinscopex/netlist_bundle.py b/backend/pinscopex/netlist_bundle.py new file mode 100644 index 0000000..3dd32ce --- /dev/null +++ b/backend/pinscopex/netlist_bundle.py @@ -0,0 +1,233 @@ +"""Unpack a netlist upload: one file, several KiCad sheets, or a zip. + +The hierarchical ``.kicad_sch`` parser needs sibling files on disk. A single +temp file named ``tmpXXXX.kicad_sch`` cannot see ``Sheetfile`` children. +""" + +from __future__ import annotations + +import io +import zipfile +from dataclasses import dataclass +from pathlib import Path + +from backend.pinscopex.parsers import detect_netlist_format + +MAX_BUNDLE_BYTES = 30 * 1024 * 1024 +_MAX_ZIP_MEMBERS = 400 + +_KIND = str # pads | edif | kicad_* | zip | kicad_pcb | unknown + + +@dataclass +class NetlistUpload: + root: Path + work_dir: Path + pcb: Path | None + extra_sch: list[Path] + + +def sniff_netlist_kind(content: bytes) -> str: + if content[:2] == b"PK": + return "zip" + head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip() + low = head[:40].lower() + if low.startswith("(kicad_pcb"): + return "kicad_pcb" + if low.startswith("(kicad_sch"): + return "kicad_sch" + if low.startswith("(edif"): + return "edif" + if low.startswith("(export"): + return "kicad_sexp" + if low.startswith(" str: + rel = name.replace("\\", "/").strip() + if not rel or rel.startswith("/") or rel.startswith("\\"): + raise ValueError(f"Rejected path: {name}") + parts = Path(rel).parts + if ".." in parts or (parts and parts[0] == ".."): + raise ValueError(f"Rejected path: {name}") + return rel + + +_KEEP_SUFFIX = { + ".kicad_sch", + ".kicad_pcb", + ".kicad_pro", + ".kicad_net", + ".xml", + ".edn", + ".edif", + ".edf", + ".asc", + ".net", +} + + +def _keep_zip_member(rel: str) -> bool: + parts = Path(rel).parts + if any( + p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"} + for p in parts + ): + return False + return Path(rel).suffix.lower() in _KEEP_SUFFIX + + +def _extract_zip(data: bytes, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + total = 0 + kept = 0 + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + rel = _safe_rel(info.filename) + if not _keep_zip_member(rel): + continue + kept += 1 + if kept > _MAX_ZIP_MEMBERS: + raise ValueError("Zip has too many schematic files") + total += max(info.file_size, 0) + if total > MAX_BUNDLE_BYTES: + raise ValueError("Zip is too large") + out = dest / rel + out.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info) as src: + payload = src.read() + if len(payload) > MAX_BUNDLE_BYTES: + raise ValueError("Zip member is too large") + out.write_bytes(payload) + + +def _write_named(name: str, data: bytes, dest: Path) -> None: + rel = _safe_rel(name) + kind = sniff_netlist_kind(data) + if kind == "zip": + _extract_zip(data, dest) + return + out = dest / Path(rel).name + # Keep a single subdirectory when the client sent webkitRelativePath. + if "/" in rel: + out = dest / rel + out.parent.mkdir(parents=True, exist_ok=True) + else: + dest.mkdir(parents=True, exist_ok=True) + out.write_bytes(data) + + +def find_kicad_pcb(work: Path) -> Path | None: + hits = sorted(p for p in work.rglob("*.kicad_pcb") if p.is_file()) + return hits[0] if hits else None + + +def _sheetfiles_of(path: Path) -> list[str]: + from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag + + text = path.read_text(encoding="utf-8", errors="replace") + tree = _parse_sexp(text) + if _tag(tree) != "kicad_sch": + return [] + return _sheetfiles(tree) + + +def _pick_root(work: Path) -> Path: + schs: list[Path] = [] + exported: list[Path] = [] + for p in work.rglob("*"): + if not p.is_file(): + continue + kind = sniff_netlist_kind(p.read_bytes()[:2048]) + if kind == "kicad_sch": + schs.append(p) + elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"): + exported.append(p) + + if exported: + pref = [ + p for p in exported + if sniff_netlist_kind(p.read_bytes()[:2048]) in ( + "kicad_xml", "kicad_sexp", "edif", + ) + ] + return (pref or exported)[0] + + if not schs: + raise ValueError( + "No schematic found. Drop the KiCad project folder or a netlist." + ) + + referenced: set[Path] = set() + for p in schs: + for rel in _sheetfiles_of(p): + try: + child = (p.parent / rel.replace("\\", "/")).resolve() + except ValueError: + continue + referenced.add(child) + roots = [p for p in schs if p.resolve() not in referenced] + if not roots: + raise ValueError("Cyclic sheet includes — upload an exported KiCad netlist instead.") + + pro = list(work.rglob("*.kicad_pro")) + if len(roots) > 1 and pro: + stems = {p.stem for p in pro} + matched = [r for r in roots if r.stem in stems] + if len(matched) == 1: + return matched[0] + if len(roots) > 1: + names = ", ".join(sorted(r.name for r in roots)) + raise ValueError( + f"Multiple root sheets ({names}). Upload a zip of the project, " + "or the top-level .kicad_sch together with every Sheetfile child." + ) + return roots[0] + + +def materialize_netlist_upload( + files: list[tuple[str, bytes]], + dest: Path, +) -> NetlistUpload: + """Write uploaded bytes into ``dest`` and return the file to parse.""" + if not files: + raise ValueError("No netlist file uploaded") + dest.mkdir(parents=True, exist_ok=True) + total = sum(len(b) for _n, b in files) + if total > MAX_BUNDLE_BYTES: + raise ValueError("Upload is too large") + + if len(files) == 1: + name, data = files[0] + kind = sniff_netlist_kind(data) + if kind == "kicad_pcb": + raise ValueError( + "This is a board file. Drop the KiCad project folder, or put " + "the .kicad_pcb on the optional board step." + ) + if kind == "unknown" and not name.lower().endswith(".zip"): + raise ValueError( + "Not a netlist. Drop the KiCad project folder, a zip, or a " + "PADS / EDIF / KiCad netlist." + ) + + for name, data in files: + _write_named(name, data, dest) + + root = _pick_root(dest) + pcb = find_kicad_pcb(dest) + extras = [ + p for p in work_sch_files(dest) + if p.resolve() != root.resolve() + ] + return NetlistUpload(root=root, work_dir=dest, pcb=pcb, extra_sch=extras) + + +def work_sch_files(dest: Path) -> list[Path]: + return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file()) diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py index ac27a89..36a6d36 100644 --- a/backend/pinscopex/parsers_kicad.py +++ b/backend/pinscopex/parsers_kicad.py @@ -556,7 +556,10 @@ def parse_kicad_sch_project( if path in seen: raise ValueError(f"Cyclic sheet include: {path.name}") if not path.is_file(): - raise ValueError(f"Missing sheet file: {path.name}") + raise ValueError( + f"Missing sheet file: {path.name}. Drop the whole KiCad " + "project folder, not a single sheet." + ) seen.add(path) text = path.read_text(encoding="utf-8", errors="replace") tree = _parse_sexp(text) diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 583989c..1a282c9 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -1,7 +1,10 @@ """Project CRUD and file upload endpoints.""" +import json +from pathlib import Path + import httpx -from fastapi import APIRouter, HTTPException, Request, UploadFile +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import JSONResponse, Response from pydantic import BaseModel @@ -462,51 +465,80 @@ async def upload_bom( @router.post("/projects/{project_id}/upload/netlist") -async def upload_netlist(project_id: str, file: UploadFile, request: Request): +async def upload_netlist( + project_id: str, + request: Request, + file: UploadFile | None = File(default=None), + files: list[UploadFile] | None = File(default=None), + paths: str | None = Form(default=None), +): storage = get_storage(request) result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id) if not result: raise HTTPException(404, "Project not found") user_id = result[0] # owner_user_id for storage paths - data = await file.read() - if len(data) > MAX_UPLOAD_BYTES: - raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)") - # Auto-detect PADS vs EDIF from the file's first bytes — users don't pick - # a format, the wizard accepts either. - from backend.pinscopex.parsers import ( - detect_netlist_format, parse_netlist_any, validate_netlist, - ) + from backend.pinscopex.netlist_bundle import materialize_netlist_upload + from backend.pinscopex.parsers import parse_netlist_any, validate_netlist from backend.pinscopex.parsers_edif import list_edif_subdesigns - import tempfile, os + import tempfile + + blobs: list[tuple[str, bytes]] = [] + seen: set[tuple[str, int]] = set() + uploads = list(files or []) if files else ([file] if file is not None else []) + rels: list[str] | None = None + if paths: + try: + parsed_paths = json.loads(paths) + except json.JSONDecodeError: + parsed_paths = None + if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths): + rels = parsed_paths + for i, uf in enumerate(uploads): + data = await uf.read() + if len(data) > MAX_UPLOAD_BYTES: + raise HTTPException( + 413, + f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)", + ) + name = ( + rels[i] + if rels is not None and i < len(rels) + else (uf.filename or "netlist") + ) + mark = (name, len(data)) + if mark in seen: + continue + seen.add(mark) + blobs.append((name, data)) + if not blobs: + raise HTTPException(400, "No netlist file uploaded") - fmt = detect_netlist_format(data) - suffix = { - "edif": ".edn", - "kicad_xml": ".xml", - "kicad_sexp": ".kicad_net", - "kicad_sch": ".kicad_sch", - }.get(fmt, ".asc") sub_designs: list[dict] = [] try: - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) - tmp.write(data) - tmp.close() - parts, nets, _ = parse_netlist_any(tmp.name) - # For EDIF, also surface the sub-design layout so the wizard can - # decide whether to prompt the user. Cheap second parse — same file. - if fmt == "edif": - sub_designs = list_edif_subdesigns(tmp.name) - os.unlink(tmp.name) + with tempfile.TemporaryDirectory() as tmp: + parsed = materialize_netlist_upload(blobs, Path(tmp) / "work") + parts, nets, fmt = parse_netlist_any(parsed.root) + if fmt == "edif": + sub_designs = list_edif_subdesigns(parsed.root) + issues = validate_netlist(parts, nets) + if issues: + raise ValueError("; ".join(issues)) + root_bytes = parsed.root.read_bytes() + key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt) + if fmt == "kicad_sch": + proj_svc.save_companion_sheets( + storage, user_id, project_id, parsed.root, parsed.extra_sch, + ) + else: + proj_svc.clear_companion_sheets(storage, user_id, project_id) + if parsed.pcb is not None: + proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes()) + except HTTPException: + raise except Exception as e: - raise HTTPException(400, f"Invalid netlist: {e}") - issues = validate_netlist(parts, nets) - if issues: - raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}") - key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt) - # EDIF: emit a designator→pins preview matching the PADS browser-side - # shape, so the wizard's power-sources step can render its dropdowns - # without re-parsing the (s-expression-heavy) file in the browser. + raise HTTPException(400, f"Netlist failed sanity check: {e}") from e + designator_pins: list[dict] = [] if fmt != "pads": designator_pins = _build_designator_pins(parts, nets) diff --git a/backend/services/projects.py b/backend/services/projects.py index db28bc8..03a1bcf 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -26,6 +26,7 @@ from __future__ import annotations import logging import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Any log = logging.getLogger(__name__) @@ -663,6 +664,34 @@ def save_netlist( return key +def clear_companion_sheets( + storage: StorageBackend, user_id: str, project_id: str, +) -> None: + prefix = f"{_project_prefix(user_id, project_id)}/uploads/" + for key in storage.list_recursive(prefix): + rel = key[len(prefix):] + if rel.endswith(".kicad_sch") and rel != "netlist.kicad_sch": + storage.delete_key(key) + + +def save_companion_sheets( + storage: StorageBackend, + user_id: str, + project_id: str, + root: Path, + extras: list[Path], +) -> None: + """Keep Sheetfile children next to ``uploads/netlist.kicad_sch``.""" + clear_companion_sheets(storage, user_id, project_id) + parent = root.parent + prefix = f"{_project_prefix(user_id, project_id)}/uploads/" + for extra in extras: + rel = extra.relative_to(parent).as_posix() + if rel == "netlist.kicad_sch": + continue + storage.write_bytes(prefix + rel, extra.read_bytes()) + + def save_pcb( storage: StorageBackend, user_id: str, project_id: str, data: bytes ) -> str: diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 44e2da0..5680749 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.26.1 — 2026-09-11 — Ask for the KiCad board + +The create-project wizard has a dedicated PCB step (skip still allowed). Report load errors show the API detail instead of a generic fetch failure. + +- [New] Wizard step for `.kicad_pcb`. Upload later from Settings or Impedance. +- [Improved] Report page retries briefly and links back to pipeline progress when `report.json` is missing. +- [New] Drop the KiCad project folder (or a zip). Extra library junk is ignored; the board is picked up if present. + ## 2.26.0 — 2026-09-11 — ImpedenceFinder Z0 on PCB nets A pipeline run with `.kicad_pcb` + stackup samples routed **signal** nets (power/ground skipped). Extra net names can be analyzed from the Impedance tab. Z0 is ImpedenceFinder net_walk/zsolver; no invented εr. diff --git a/frontend/content/file-guide.md b/frontend/content/file-guide.md index f21b9cd..478995e 100644 --- a/frontend/content/file-guide.md +++ b/frontend/content/file-guide.md @@ -1,9 +1,10 @@ # File Upload Guide -Pinscope needs two files from your EDA tool to review a design: +Pinscope needs two files from your EDA tool to review a schematic, and an optional KiCad board for layout: - A **PADS-PCB ASCII netlist** or an **EDIF 2.0.0 netlist** — the circuit's connectivity. Pinscope accepts `.asc`, `.net`, `.NET`, `.txt` (PADS-PCB) and `.edn`, `.edif`, `.edf` (EDIF); the format is auto-detected from the file's first bytes. - A **Bill of Materials** (CSV or XLSX) — mapping each reference designator to a manufacturer part number. +- Optional: a **KiCad PCB** (`.kicad_pcb`) — placement, keepout, pair length, and net Z0. Schematic review still runs without it. ## Example files @@ -179,4 +180,8 @@ For both EasyEDA Standard and EasyEDA Pro. - **"No ground net found"** — your netlist has no net named `GND`, `VSS`, `AGND`, `DGND`, or similar. If you exported a sub-sheet, re-export the top sheet instead. - **Unresolved parts after the pipeline runs** — a BOM row has no MPN, or the MPN wasn't found on DigiKey. Add the MPN, or rely on Pinscope's value fallback (fills in from the `Value` / `Comment` column). +## The KiCad PCB (optional) + +Drop the KiCad **project folder** (or a zip of it) on Schematic — Pinscope takes the sheets and the `.kicad_pcb` if it is there. You can also add the board later. + Still stuck? [Contact us](/contact) with your netlist and BOM attached and we'll take a look. diff --git a/frontend/package.json b/frontend/package.json index 6947739..c309ec1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "2.26.0", + "version": "2.26.1", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index cf06e45..2cfaa2f 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -41,6 +41,7 @@ import { } from "lucide-react"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { ImpedancePanel } from "@/components/project/impedance-panel"; +import { PcbUploadButton } from "@/components/project/pcb-upload"; export default function ProjectDetailPage({ params, @@ -190,6 +191,11 @@ export default function ProjectDetailPage({

{project.name}

{new Date(project.created).toLocaleDateString()} + {project.hasPcb ? ( + · PCB uploaded + ) : ( + · no PCB + )} {typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && ( ${project.totalCostUsd.toFixed(4)} @@ -348,7 +354,11 @@ export default function ProjectDetailPage({ )} {tab === "impedance" && ( - + )} {tab === "logs" && ( @@ -357,6 +367,19 @@ export default function ProjectDetailPage({ {tab === "settings" && (

+ + + KiCad PCB + + +

+ {project.hasPcb + ? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0." + : "No board yet. Schematic review does not need one. Layout checks and net Z0 do."} +

+ +
+
diff --git a/frontend/src/app/(app)/project/[id]/progress/page.tsx b/frontend/src/app/(app)/project/[id]/progress/page.tsx index 0594169..c4e94ba 100644 --- a/frontend/src/app/(app)/project/[id]/progress/page.tsx +++ b/frontend/src/app/(app)/project/[id]/progress/page.tsx @@ -20,7 +20,7 @@ import { import { PipelineStepper } from "@/components/progress/pipeline-stepper"; import { PausedRunBanner } from "@/components/billing/paused-run-banner"; import { usePipelineProgress } from "@/hooks/use-pipeline-progress"; -import { cancelPipeline, fetchProject, resumePipeline, reprocessPipeline } from "@/lib/api"; +import { cancelPipeline, fetchProject, fetchReport, resumePipeline, reprocessPipeline } from "@/lib/api"; import type { PauseCheckpoint } from "@/lib/types"; import { AlertTriangle, @@ -107,14 +107,24 @@ export default function ProgressPage({ } }; - // Auto-navigate to report when pipeline completes successfully + // Auto-navigate to report when pipeline completes and the file exists useEffect(() => { - if (done && !error && !cancelled && !projectPaused) { - const timer = setTimeout(() => { - router.push(`/project/${id}/report`); - }, 1500); - return () => clearTimeout(timer); - } + if (!done || error || cancelled || projectPaused) return; + let stopped = false; + (async () => { + for (let i = 0; i < 8; i++) { + try { + await fetchReport(id); + if (!stopped) router.push(`/project/${id}/report`); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + })(); + return () => { + stopped = true; + }; }, [done, error, cancelled, projectPaused, id, router]); // Auto-navigate to dashboard when pipeline is cancelled diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx index a367567..3a17003 100644 --- a/frontend/src/app/(app)/project/[id]/report/page.tsx +++ b/frontend/src/app/(app)/project/[id]/report/page.tsx @@ -220,8 +220,18 @@ function ReportContent({ projectId }: { projectId: string }) { if (error || !report || !graph) { return ( -
- {error ?? "Report not found."} +
+

+ {error ?? "Report not found."} +

+

+ + Open pipeline progress + +

); } diff --git a/frontend/src/components/dashboard/create-project-dialog.tsx b/frontend/src/components/dashboard/create-project-dialog.tsx index 9df2fa7..b67dc52 100644 --- a/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/frontend/src/components/dashboard/create-project-dialog.tsx @@ -352,6 +352,7 @@ function summarizeLcscModel(model: Record): string { type WizardStep = | "details" + | "pcb" | "columns" | "subdesigns" | "lcsc-passives" @@ -361,6 +362,7 @@ type WizardStep = const ALL_STEPS: { key: WizardStep; label: string }[] = [ { key: "details", label: "Project Details" }, + { key: "pcb", label: "Board (optional)" }, { key: "columns", label: "BOM Columns" }, { key: "subdesigns", label: "Sub-designs" }, { key: "lcsc-passives", label: "Resolving Passive Specs" }, @@ -374,6 +376,45 @@ const ALL_STEPS: { key: WizardStep; label: string }[] = [ const NULL_SUBDESIGN_KEY = ""; const _subKey = (id: string | null): string => id ?? NULL_SUBDESIGN_KEY; +const _NET_EXT = new Set([ + ".kicad_sch", + ".kicad_net", + ".kicad_pro", + ".asc", + ".net", + ".edn", + ".edif", + ".edf", + ".xml", + ".zip", +]); + +function _fileExt(name: string): string { + const i = name.lastIndexOf("."); + return i >= 0 ? name.slice(i).toLowerCase() : ""; +} + +function splitProjectFiles(incoming: File[]): { + netlist: File[]; + pcb: File[]; + bom: File[]; +} { + const netlist: File[] = []; + const pcb: File[] = []; + const bom: File[] = []; + for (const f of incoming) { + const rel = ( + (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name + ).toLowerCase(); + if (rel.includes("-backups/") || rel.includes(".pretty/")) continue; + const ext = _fileExt(f.name); + if (ext === ".kicad_pcb") pcb.push(f); + else if (ext === ".csv" || ext === ".xlsx") bom.push(f); + else if (_NET_EXT.has(ext)) netlist.push(f); + } + return { netlist, pcb, bom }; +} + // Concurrency cap for the per-row LCSC passive resolve. Backend charges one // credit-billing API call per request; small batch keeps latency reasonable // without overloading the resolve endpoint. @@ -533,7 +574,7 @@ export function CreateProjectDialog({ // Rerun mode: operate on an existing project rather than creating new. const [existingProjectId, setExistingProjectId] = useState(null); const [initialBomFile, setInitialBomFile] = useState(null); - const [initialNetlistFile, setInitialNetlistFile] = useState(null); + const [initialNetlistFiles, setInitialNetlistFiles] = useState([]); const [existingDatasheetStems, setExistingDatasheetStems] = useState>( new Set(), ); @@ -548,8 +589,12 @@ export function CreateProjectDialog({ // Step 1 const [name, setName] = useState(""); const [bomFile, setBomFile] = useState(null); - const [netlistFile, setNetlistFile] = useState(null); + const [netlistFiles, setNetlistFiles] = useState([]); + const netlistFile = netlistFiles[0] ?? null; const [pcbFile, setPcbFile] = useState(null); + const skipPcbStep = + Boolean(pcbFile) || + netlistFiles.some((f) => f.name.toLowerCase().endsWith(".zip")); const [netlistNetCount, setNetlistNetCount] = useState(null); const [netlistError, setNetlistError] = useState(null); @@ -840,6 +885,7 @@ export function CreateProjectDialog({ // lcsc-passives drains to zero before the auto-advance effect // moves us off the step on the next tick). if (s.key === step) return true; + if (s.key === "pcb") return !skipPcbStep; if (s.key === "subdesigns") return hasSubdesignChoice; if (s.key === "lcsc-passives") return hasLcscPassives; if (s.key === "datasheets") return hasIcs; @@ -847,14 +893,15 @@ export function CreateProjectDialog({ if (s.key === "passives") return hasPassives; return true; }); - }, [step, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]); + }, [step, skipPcbStep, hasSubdesignChoice, hasLcscPassives, hasIcs, hasSimple, hasPassives]); const stepIndex = activeSteps.findIndex((s) => s.key === step); // ---- Handlers ---- const handleBomChange = useCallback((files: File[]) => { - const file = files[0] || null; + const file = + files.find((f) => /\.(csv|xlsx)$/i.test(f.name)) || files[0] || null; setBomFile(file); if (!file) { setCsvData(null); @@ -880,9 +927,13 @@ export function CreateProjectDialog({ } }, []); - const handleNetlistChange = useCallback((files: File[]) => { + const handleNetlistChange = useCallback((incoming: File[]) => { + const { netlist, pcb, bom } = splitProjectFiles(incoming); + if (pcb[0]) setPcbFile(pcb[0]); + if (bom[0]) handleBomChange([bom[0]]); + const files = netlist; const file = files[0] || null; - setNetlistFile(file); + setNetlistFiles(files); setNetlistNetCount(null); setNetlistError(null); // Invalidate any cached netlist preview, since it was relative to the @@ -895,6 +946,11 @@ export function CreateProjectDialog({ setNetlistUploadedEarly(false); setNetlistIsEdif(false); if (!file) return; + const lower = file.name.toLowerCase(); + if (lower.endsWith(".zip") || files.length > 1) { + setNetlistPreview([]); + return; + } file.text().then((text) => { // EDIF: skip browser-side preview — the net-count badge will populate // after the server upload parses the file. @@ -920,14 +976,15 @@ export function CreateProjectDialog({ ); } }); - }, []); + }, [handleBomChange]); const resetAndClose = useCallback(() => { setOpen(false); setStep("details"); setName(""); setBomFile(null); - setNetlistFile(null); + setNetlistFiles([]); + setPcbFile(null); setNetlistNetCount(null); setNetlistError(null); setCsvData(null); @@ -954,7 +1011,7 @@ export function CreateProjectDialog({ setError(null); setExistingProjectId(null); setInitialBomFile(null); - setInitialNetlistFile(null); + setInitialNetlistFiles([]); setExistingDatasheetStems(new Set()); setPrefillLoading(false); setPrefillError(null); @@ -1049,8 +1106,8 @@ export function CreateProjectDialog({ if (netlist) { const netlistText = await netlist.text(); if (cancelled) return; - setNetlistFile(netlist); - setInitialNetlistFile(netlist); + setNetlistFiles([netlist]); + setInitialNetlistFiles([netlist]); if (isEdifNetlist(netlistText)) { setNetlistIsEdif(true); // EDIF: PADS-shape browser preview doesn't apply. @@ -1135,7 +1192,7 @@ export function CreateProjectDialog({ if (netlist) { const netlistText = await netlist.text(); if (cancelled) return; - setNetlistFile(netlist); + setNetlistFiles([netlist]); if (isEdifNetlist(netlistText)) { setNetlistIsEdif(true); setNetlistNetCount(null); @@ -1488,7 +1545,7 @@ export function CreateProjectDialog({ setBomUploadedEarly(true); setInitialBomFile(bomFile); // Treat the netlist as fresh so it still uploads in handleCreate. - setInitialNetlistFile(null); + setInitialNetlistFiles([]); const map = uploadRes.lcsc_to_mpn ?? {}; if (Object.keys(map).length > 0) setLcscToMpn(map); if (fullProj.lcscPayloads && Object.keys(fullProj.lcscPayloads).length > 0) { @@ -1547,13 +1604,13 @@ export function CreateProjectDialog({ createdProjectIdHere = proj.id; setExistingProjectId(proj.id); } - const result = await uploadNetlist(projectId, netlistFile); + const result = await uploadNetlist(projectId, netlistFiles); setEdifSubDesigns(result.sub_designs); if (result.designator_pins.length > 0) { setNetlistPreview(result.designator_pins); } setNetlistUploadedEarly(true); - setInitialNetlistFile(netlistFile); + setInitialNetlistFiles(netlistFiles); return { ok: true, subDesigns: result.sub_designs }; } catch (e) { // Only delete the project if we created it just now — leave LCSC's @@ -1573,7 +1630,7 @@ export function CreateProjectDialog({ } finally { setEarlyUploading(false); } - }, [netlistFile, existingProjectId, name]); + }, [netlistFile, netlistFiles, existingProjectId, name]); // ---- LCSC per-row passive resolve ---- // @@ -1689,6 +1746,7 @@ export function CreateProjectDialog({ const canAdvance = (): boolean => { if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError); + if (step === "pcb") return true; if (step === "columns") return !!(refCol && mpnCol); if (step === "subdesigns") return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0); @@ -1729,6 +1787,9 @@ export function CreateProjectDialog({ const res = await runEarlyNetlistUpload(); if (!res.ok) return; } + setStep(skipPcbStep ? "columns" : "pcb"); + } + else if (step === "pcb") { setStep("columns"); } else if (step === "columns") { @@ -1806,7 +1867,8 @@ export function CreateProjectDialog({ const goBack = () => { setError(null); - if (step === "columns") setStep("details"); + if (step === "pcb") setStep("details"); + else if (step === "columns") setStep(skipPcbStep ? "details" : "pcb"); else if (step === "subdesigns") setStep("columns"); else if (step === "lcsc-passives") { if (hasSubdesignChoice) setStep("subdesigns"); @@ -1877,9 +1939,9 @@ export function CreateProjectDialog({ ); } - if (!projectAlreadyExists || netlistFile !== initialNetlistFile) { + if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) { setProgress("Uploading netlist..."); - await uploadNetlist(project.id, netlistFile!); + await uploadNetlist(project.id, netlistFiles); } if (pcbFile) { setProgress("Uploading PCB..."); @@ -2065,14 +2127,13 @@ export function CreateProjectDialog({
+ )} + + {step === "pcb" && ( +
+

+ Optional. Skip if you only want schematic review. +

+ setPcbFile(files[0] ?? null)} + />
)} diff --git a/frontend/src/components/project/impedance-panel.tsx b/frontend/src/components/project/impedance-panel.tsx index a063d59..6a1cea6 100644 --- a/frontend/src/components/project/impedance-panel.tsx +++ b/frontend/src/components/project/impedance-panel.tsx @@ -10,6 +10,7 @@ import { computeImpedance, fetchImpedanceNets, } from "@/lib/api"; +import { PcbUploadButton } from "@/components/project/pcb-upload"; import type { ImpedanceKind, ImpedanceNetsReport, @@ -29,9 +30,11 @@ function fmt(n: number | null | undefined, digits = 2): string { export function ImpedancePanel({ projectId, hasPcb, + onPcbUploaded, }: { projectId: string; hasPcb: boolean; + onPcbUploaded?: () => void; }) { const [kind, setKind] = useState("microstrip"); const [h, setH] = useState("0.20"); @@ -225,10 +228,13 @@ export function ImpedancePanel({ {!hasPcb && ( -

- Upload a `.kicad_pcb` and run analysis. Power/ground nets are - skipped; signal traces with stackup εr/h are sampled. -

+
+

+ Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground + nets are skipped; signal traces with stackup εr/h are sampled. +

+ +
)} {hasPcb && boardNets?.skipped && (

{boardNets.skipped}

diff --git a/frontend/src/components/project/pcb-upload.tsx b/frontend/src/components/project/pcb-upload.tsx new file mode 100644 index 0000000..0ab2cc4 --- /dev/null +++ b/frontend/src/components/project/pcb-upload.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { Upload, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { uploadPcb } from "@/lib/api"; + +export function PcbUploadButton({ + projectId, + onUploaded, +}: { + projectId: string; + onUploaded?: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/upload/file-upload-zone.tsx b/frontend/src/components/upload/file-upload-zone.tsx index cfd57e4..c28022b 100644 --- a/frontend/src/components/upload/file-upload-zone.tsx +++ b/frontend/src/components/upload/file-upload-zone.tsx @@ -13,6 +13,80 @@ interface FileUploadZoneProps { preloaded?: string[]; } +function withRelativePath(file: File, rel: string): File { + if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) { + return file; + } + try { + Object.defineProperty(file, "webkitRelativePath", { + value: rel, + configurable: true, + }); + } catch { + /* ignore — some File objects are sealed */ + } + return file; +} + +function readDirEntries( + reader: FileSystemDirectoryReader, +): Promise { + return new Promise((resolve, reject) => { + const acc: FileSystemEntry[] = []; + const pump = () => { + reader.readEntries((chunk) => { + if (chunk.length === 0) resolve(acc); + else { + acc.push(...chunk); + pump(); + } + }, reject); + }; + pump(); + }); +} + +async function filesFromEntry( + entry: FileSystemEntry, + prefix: string, +): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject); + }); + return [withRelativePath(file, prefix + file.name)]; + } + if (entry.isDirectory) { + const skip = /(-backups|\.pretty|3dmodels|__macosx)$/i.test(entry.name); + if (skip) return []; + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children = await readDirEntries(reader); + const nested: File[] = []; + for (const child of children) { + nested.push(...(await filesFromEntry(child, prefix + entry.name + "/"))); + } + return nested; + } + return []; +} + +async function filesFromDrop(e: React.DragEvent): Promise { + const items = e.dataTransfer?.items; + if (items && items.length > 0) { + const out: File[] = []; + for (const item of Array.from(items)) { + const entry = item.webkitGetAsEntry?.(); + if (entry) out.push(...(await filesFromEntry(entry, ""))); + else { + const f = item.getAsFile(); + if (f) out.push(f); + } + } + if (out.length > 0) return out; + } + return Array.from(e.dataTransfer?.files ?? []); +} + export function FileUploadZone({ label, accept, @@ -27,10 +101,11 @@ export function FileUploadZone({ (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); - const dropped = Array.from(e.dataTransfer.files); - onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1)); + void filesFromDrop(e).then((dropped) => { + onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1)); + }); }, - [files, multiple, onFilesChange] + [files, multiple, onFilesChange], ); const handleChange = useCallback( @@ -38,7 +113,7 @@ export function FileUploadZone({ const selected = Array.from(e.target.files ?? []); onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1)); }, - [files, multiple, onFilesChange] + [files, multiple, onFilesChange], ); const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0); @@ -48,9 +123,12 @@ export function FileUploadZone({ className={cn( "flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors", dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20", - hasFiles && "border-emerald-500/40 bg-emerald-500/5" + hasFiles && "border-emerald-500/40 bg-emerald-500/5", )} - onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} > @@ -69,12 +147,27 @@ export function FileUploadZone({ )} {files.length > 0 && (
- {files.map((f) => ( - {f.name} - ))} + {files.length <= 2 ? ( + files.map((f) => { + const rel = + (f as File & { webkitRelativePath?: string }).webkitRelativePath || + f.name; + return ( + {rel.split("/").pop()} + ); + }) + ) : ( + {files.length} files + )}
)} - + ); } diff --git a/frontend/src/hooks/use-report.ts b/frontend/src/hooks/use-report.ts index ba0a0b0..9b5c62a 100644 --- a/frontend/src/hooks/use-report.ts +++ b/frontend/src/hooks/use-report.ts @@ -4,6 +4,13 @@ import { useState, useEffect } from "react"; import type { ValidationReport, DesignGraph } from "@/lib/types"; import { fetchReport, fetchGraph } from "@/lib/api"; +function describeLoadError(e: unknown): string { + if (e instanceof TypeError) { + return "Could not reach the Pinscope API. Check that the backend is running and that /api is proxied to it."; + } + return e instanceof Error ? e.message : "Failed to load report"; +} + export function useReport(projectId: string) { const [report, setReport] = useState(null); const [graph, setGraph] = useState(null); @@ -11,14 +18,39 @@ export function useReport(projectId: string) { const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; setLoading(true); - Promise.all([fetchReport(projectId), fetchGraph(projectId)]) - .then(([r, g]) => { - setReport(r); - setGraph(g); - }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + setError(null); + + (async () => { + let lastErr: unknown; + for (let attempt = 0; attempt < 4; attempt++) { + try { + const [r, g] = await Promise.all([ + fetchReport(projectId), + fetchGraph(projectId), + ]); + if (!cancelled) { + setReport(r); + setGraph(g); + } + return; + } catch (e) { + lastErr = e; + const msg = e instanceof Error ? e.message : ""; + const retryable = msg.includes("not found") || e instanceof TypeError; + if (!retryable || attempt === 3) break; + await new Promise((r) => setTimeout(r, 400 * (attempt + 1))); + } + } + if (!cancelled) setError(describeLoadError(lastErr)); + })().finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; }, [projectId]); return { report, graph, loading, error }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 042b752..d8695b9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -56,6 +56,19 @@ async function authFetch(url: string, init?: RequestInit): Promise { return fetch(url, { ...init, headers }); } +async function throwHttpError(res: Response, fallback: string): Promise { + let detail: unknown; + try { + const body = await res.json(); + detail = (body as { detail?: unknown; error?: unknown }).detail + ?? (body as { error?: unknown }).error; + } catch { + detail = undefined; + } + const msg = typeof detail === "string" && detail.trim() ? detail : fallback; + throw new Error(msg); +} + // --- Projects --- function mapProject(p: Record): Project { @@ -301,10 +314,19 @@ export async function uploadPcb( } export async function uploadNetlist( - projectId: string, file: File, + projectId: string, files: File | File[], ): Promise { + const list = Array.isArray(files) ? files : [files]; const form = new FormData(); - form.append("file", file); + const rels = list.map((f) => { + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; + return rel && rel.length > 0 ? rel : f.name; + }); + if (list[0]) form.append("file", list[0], list[0].name); + if (list.length > 1) { + for (const f of list) form.append("files", f, f.name); + form.append("paths", JSON.stringify(rels)); + } const res = await authFetch( `${BASE}/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form }, @@ -651,7 +673,14 @@ export async function fetchReport( projectId: string, ): Promise { const res = await authFetch(`${BASE}/api/report/${projectId}`); - if (!res.ok) throw new Error("Failed to fetch report"); + if (!res.ok) { + await throwHttpError( + res, + res.status === 404 + ? "Report not found — the pipeline has not finished, or it failed before writing a report." + : "Failed to fetch report", + ); + } return res.json(); } @@ -723,7 +752,14 @@ export async function deleteComment( export async function fetchGraph(projectId: string): Promise { const res = await authFetch(`${BASE}/api/graph/${projectId}`); - if (!res.ok) throw new Error("Failed to fetch graph"); + if (!res.ok) { + await throwHttpError( + res, + res.status === 404 + ? "Design graph not found — the pipeline has not finished, or it failed during graph build." + : "Failed to fetch graph", + ); + } return res.json(); } diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index e68b397..672eb8b 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md. // Do not edit by hand — change the top "## X.Y.Z" heading in the changelog. -export const APP_VERSION = "2.26.0"; +export const APP_VERSION = "2.26.1"; export const APP_VERSION_DATE = "2026-09-11"; diff --git a/tests/test_netlist_bundle.py b/tests/test_netlist_bundle.py new file mode 100644 index 0000000..760dd9c --- /dev/null +++ b/tests/test_netlist_bundle.py @@ -0,0 +1,202 @@ +"""Multi-file / zip KiCad schematic ingest.""" + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path + +import pytest + +from backend.pinscopex.netlist_bundle import ( + find_kicad_pcb, + materialize_netlist_upload, + sniff_netlist_kind, +) +from backend.pinscopex.parsers import parse_netlist_any, validate_netlist + +_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)))) + ) + ) + ) +""" + + +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 _sch(*body: str) -> str: + return ( + "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + + _LIB_R + + "".join(body) + + "\n)\n" + ) + + +def _root_with_child() -> tuple[str, str]: + child = _sch( + _resistor("C1", "100n"), + """ + (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" "Child" (at 50 0 0) (effects (font (size 1.27 1.27)))) + (property "Sheetfile" "child.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27)))) + ) +""", + ) + return root, child + + +def test_sniff_rejects_pcb_as_netlist(): + assert sniff_netlist_kind(b"(kicad_pcb (version 1)\n") == "kicad_pcb" + assert sniff_netlist_kind(b"PK\x03\x04rest") == "zip" + assert sniff_netlist_kind(b"(kicad_sch (version 1)") == "kicad_sch" + assert sniff_netlist_kind(b"*PADS-PCB*\n*PART*\n") == "pads" + + +def test_root_alone_missing_child_explains_multi_file(tmp_path: Path): + from backend.pinscopex.parsers_kicad import parse_kicad + + root, _child = _root_with_child() + parsed = materialize_netlist_upload( + [("root.kicad_sch", root.encode())], + tmp_path / "work", + ) + with pytest.raises(ValueError, match="child.kicad_sch"): + parse_kicad(parsed.root) + + +def test_multiple_sch_files_parse(tmp_path: Path): + root, child = _root_with_child() + parsed = materialize_netlist_upload( + [ + ("root.kicad_sch", root.encode()), + ("child.kicad_sch", child.encode()), + ], + tmp_path / "work", + ) + parts, nets, fmt = parse_netlist_any(parsed.root) + assert fmt == "kicad_sch" + assert "R1" in parts and "C1" in parts + assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"] + assert validate_netlist(parts, nets) == [] + assert parsed.pcb is None + + +def test_zip_with_nested_folder_and_pcb(tmp_path: Path): + root, child = _root_with_child() + pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n' + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("board/root.kicad_sch", root) + zf.writestr("board/child.kicad_sch", child) + zf.writestr("board/board.kicad_pcb", pcb) + parsed = materialize_netlist_upload( + [("board.zip", buf.getvalue())], + tmp_path / "work", + ) + parts, nets, _fmt = parse_netlist_any(parsed.root) + assert "R1" in parts and "C1" in parts + assert parsed.pcb is not None + assert find_kicad_pcb(parsed.work_dir) is not None + + +def test_zip_slip_is_rejected(tmp_path: Path): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("../escape.kicad_sch", _sch(_resistor("R1", "1k"))) + with pytest.raises(ValueError, match="Rejected"): + materialize_netlist_upload( + [("bad.zip", buf.getvalue())], + tmp_path / "work", + ) + + +def test_api_accepts_zip_and_companion_sheets(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": "kicad"}).json()["id"] + root, child = _root_with_child() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("root.kicad_sch", root) + zf.writestr("child.kicad_sch", child) + resp = client.post( + f"/api/projects/{pid}/upload/netlist", + files={"file": ("board.zip", buf.getvalue(), "application/zip")}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["parts"] == 2 + assert body["format"] == "kicad_sch" + storage = client.app.state.storage + prefix = f"users/local/projects/{pid}/uploads/" + assert storage.exists(prefix + "netlist.kicad_sch") + assert storage.exists(prefix + "child.kicad_sch") + + +def test_api_accepts_multiple_sch_files(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": "multi"}).json()["id"] + root, child = _root_with_child() + resp = client.post( + f"/api/projects/{pid}/upload/netlist", + files=[ + ("files", ("root.kicad_sch", root.encode(), "text/plain")), + ("files", ("child.kicad_sch", child.encode(), "text/plain")), + ], + data={"paths": '["root.kicad_sch", "child.kicad_sch"]'}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["parts"] == 2 + + + +def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path): + with pytest.raises(ValueError, match="board"): + materialize_netlist_upload( + [("board.kicad_pcb", b"(kicad_pcb (version 1)\n")], + tmp_path / "work", + )