Compare commits
37 Commits
779d50a1fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ba3d1fa4 | |||
|
|
99b1d8a168 | ||
|
|
48f2d09fdd | ||
|
|
263195a07d | ||
|
|
fcf23f1965 | ||
|
|
a094d1b937 | ||
|
|
602d76af23 | ||
|
|
07bad447c1 | ||
|
|
7fa6c350f4 | ||
|
|
10b9736604 | ||
|
|
4db886e15a | ||
|
|
59aed24d05 | ||
|
|
12523c7575 | ||
|
|
605dac7fd3 | ||
|
|
d48f4835b0 | ||
|
|
91477e8d02 | ||
|
|
c893509993 | ||
|
|
cff3addc95 | ||
|
|
95f803900d | ||
|
|
b257b7276d | ||
|
|
7210e0b59c | ||
|
|
f23f1f658a | ||
|
|
0faef07650 | ||
|
|
ce6cff1b39 | ||
|
|
494c2e1ffb | ||
|
|
f491df4eea | ||
|
|
f6d38f1942 | ||
|
|
ab7a2101af | ||
|
|
cdaa1eb635 | ||
|
|
0f07ccd593 | ||
|
|
88226d567b | ||
|
|
b37ca6a225 | ||
|
|
82e5de7aa9 | ||
|
|
2fb54802b6 | ||
|
|
59b5eb0bd2 | ||
|
|
409b18c83d | ||
|
|
3f55a09661 |
220
.github/workflows/ci.yml
vendored
220
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest]
|
os: [ubuntu-24.04, ubuntu-22.04, windows-latest, macos-latest]
|
||||||
node-version: [18.x, 20.x, 22.x]
|
node-version: [20.x, 22.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -33,10 +33,11 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
|
shell: bash
|
||||||
run: npm run lint || echo "Linter not configured yet"
|
run: npm run lint || echo "Linter not configured yet"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run TypeScript tests
|
||||||
run: npm test || echo "Tests not configured yet"
|
run: npm run test:ts
|
||||||
|
|
||||||
# Python tests
|
# Python tests
|
||||||
python-tests:
|
python-tests:
|
||||||
@@ -88,46 +89,225 @@ jobs:
|
|||||||
integration-tests:
|
integration-tests:
|
||||||
name: Integration Tests (Linux + KiCAD)
|
name: Integration Tests (Linux + KiCAD)
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
kicad: ["8.0", "9.0", "10.0"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20.x"
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|
||||||
- name: Add KiCAD PPA and Install KiCAD 9.0
|
- name: Add KiCAD PPA and Install KiCAD ${{ matrix.kicad }}
|
||||||
run: |
|
run: |
|
||||||
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
sudo add-apt-repository --yes ppa:kicad/kicad-${{ matrix.kicad }}-releases
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y kicad kicad-libraries
|
sudo apt-get install -y kicad kicad-libraries
|
||||||
|
|
||||||
|
mapfile -t KICAD_PYTHON_PACKAGES < <(
|
||||||
|
{
|
||||||
|
apt-cache search kicad
|
||||||
|
apt-cache search pcbnew
|
||||||
|
} | awk '
|
||||||
|
BEGIN { IGNORECASE = 1 }
|
||||||
|
{
|
||||||
|
name = $1
|
||||||
|
line = tolower($0)
|
||||||
|
if (name !~ /nightly/ && (line ~ /kicad/ || line ~ /pcbnew/) && line ~ /python/) print name
|
||||||
|
}
|
||||||
|
' | sort -u
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "${#KICAD_PYTHON_PACKAGES[@]}" -gt 0 ]; then
|
||||||
|
echo "Installing KiCad Python packages: ${KICAD_PYTHON_PACKAGES[*]}"
|
||||||
|
sudo apt-get install -y "${KICAD_PYTHON_PACKAGES[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
KICAD_PYTHONPATH="$(python3 - <<'PY'
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
|
||||||
|
def add_candidate(root: Path) -> None:
|
||||||
|
if not root.exists():
|
||||||
|
return
|
||||||
|
root_str = str(root)
|
||||||
|
if root_str not in seen:
|
||||||
|
seen.add(root_str)
|
||||||
|
candidates.append(root_str)
|
||||||
|
|
||||||
|
|
||||||
|
def add_candidate_for_file(file_path: Path) -> None:
|
||||||
|
if file_path.name == "__init__.py" and file_path.parent.name == "pcbnew":
|
||||||
|
add_candidate(file_path.parent.parent)
|
||||||
|
return
|
||||||
|
if file_path.parent.name == "pcbnew":
|
||||||
|
add_candidate(file_path.parent.parent)
|
||||||
|
return
|
||||||
|
add_candidate(file_path.parent)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_pcbnew(file_path: Path) -> bool:
|
||||||
|
name = file_path.name
|
||||||
|
if name == "pcbnew.py":
|
||||||
|
return True
|
||||||
|
if name == "__init__.py" and file_path.parent.name == "pcbnew":
|
||||||
|
return True
|
||||||
|
return name.endswith(".so") and (
|
||||||
|
name.startswith("pcbnew") or name.startswith("_pcbnew")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_from_package(package: str) -> None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["dpkg", "-L", package],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for raw_path in result.stdout.splitlines():
|
||||||
|
path = Path(raw_path)
|
||||||
|
if path.is_file() and looks_like_pcbnew(path):
|
||||||
|
add_candidate_for_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
package_result = subprocess.run(
|
||||||
|
["dpkg-query", "-W", "-f=${Package}\n"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
installed_packages = [
|
||||||
|
package
|
||||||
|
for package in package_result.stdout.splitlines()
|
||||||
|
if "kicad" in package or "pcbnew" in package
|
||||||
|
]
|
||||||
|
|
||||||
|
for package in installed_packages:
|
||||||
|
collect_from_package(package)
|
||||||
|
|
||||||
|
file_patterns = [
|
||||||
|
"/usr/lib/python3/dist-packages/pcbnew.py",
|
||||||
|
"/usr/lib/python3/dist-packages/pcbnew*.so",
|
||||||
|
"/usr/lib/python3/dist-packages/_pcbnew*.so",
|
||||||
|
"/usr/lib/python3/dist-packages/pcbnew/__init__.py",
|
||||||
|
"/usr/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
|
||||||
|
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew.py",
|
||||||
|
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew*.so",
|
||||||
|
"/usr/lib/kicad*/lib/python3/dist-packages/_pcbnew*.so",
|
||||||
|
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/__init__.py",
|
||||||
|
"/usr/lib/kicad*/lib/python3/dist-packages/pcbnew/_pcbnew*.so",
|
||||||
|
"/usr/lib/*/dist-packages/pcbnew.py",
|
||||||
|
"/usr/lib/*/dist-packages/pcbnew*.so",
|
||||||
|
"/usr/lib/*/dist-packages/_pcbnew*.so",
|
||||||
|
"/usr/lib/*/dist-packages/pcbnew/__init__.py",
|
||||||
|
"/usr/lib/*/dist-packages/pcbnew/_pcbnew*.so",
|
||||||
|
"/usr/local/lib/python*/dist-packages/pcbnew.py",
|
||||||
|
"/usr/local/lib/python*/dist-packages/pcbnew*.so",
|
||||||
|
"/usr/local/lib/python*/dist-packages/_pcbnew*.so",
|
||||||
|
"/usr/local/lib/python*/dist-packages/pcbnew/__init__.py",
|
||||||
|
"/usr/local/lib/python*/dist-packages/pcbnew/_pcbnew*.so",
|
||||||
|
"/usr/share/kicad*/scripting/pcbnew.py",
|
||||||
|
"/usr/share/kicad*/scripting/pcbnew*.so",
|
||||||
|
"/usr/share/kicad*/scripting/_pcbnew*.so",
|
||||||
|
"/usr/share/kicad*/scripting/pcbnew/__init__.py",
|
||||||
|
"/usr/share/kicad*/scripting/pcbnew/_pcbnew*.so",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in file_patterns:
|
||||||
|
for raw_path in glob.glob(pattern):
|
||||||
|
path = Path(raw_path)
|
||||||
|
if path.is_file() and looks_like_pcbnew(path):
|
||||||
|
add_candidate_for_file(path)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
for file_path in Path("/usr").rglob("*"):
|
||||||
|
if file_path.is_file() and looks_like_pcbnew(file_path):
|
||||||
|
add_candidate_for_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def import_succeeds(candidate: str) -> bool:
|
||||||
|
env = os.environ.copy()
|
||||||
|
existing = env.get("PYTHONPATH", "")
|
||||||
|
env["PYTHONPATH"] = (
|
||||||
|
f"{candidate}:{existing}" if existing else candidate
|
||||||
|
)
|
||||||
|
probe = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
"import pcbnew; "
|
||||||
|
"print(getattr(pcbnew, '__file__', '')); "
|
||||||
|
"print(pcbnew.GetBuildVersion())"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if probe.returncode == 0:
|
||||||
|
print(
|
||||||
|
f"Using pcbnew from {probe.stdout.splitlines()[0]}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
working_candidates = [candidate for candidate in candidates if import_succeeds(candidate)]
|
||||||
|
print(":".join(working_candidates))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$KICAD_PYTHONPATH" ]; then
|
||||||
|
echo "Unable to locate pcbnew Python bindings after installing KiCad ${{ matrix.kicad }}" >&2
|
||||||
|
{
|
||||||
|
apt-cache search kicad
|
||||||
|
apt-cache search pcbnew
|
||||||
|
} | awk 'BEGIN { IGNORECASE = 1 } tolower($0) ~ /python/ { print }' || true
|
||||||
|
dpkg-query -W -f='${Package}\n' | grep -Ei 'kicad|pcbnew' || true
|
||||||
|
find /usr -path '*dist-packages*' \( -name 'pcbnew.py' -o -name 'pcbnew*.so' -o -name '_pcbnew*.so' \) -print || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "KICAD_PYTHONPATH=$KICAD_PYTHONPATH" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Verify KiCAD installation
|
- name: Verify KiCAD installation
|
||||||
run: |
|
run: |
|
||||||
kicad-cli version || echo "kicad-cli not found"
|
export KICAD_CLI="kicad-cli"
|
||||||
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')" || echo "pcbnew module not found"
|
export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
|
||||||
|
"$KICAD_CLI" version
|
||||||
|
python3 -c "import pcbnew; print(f'pcbnew version: {pcbnew.GetBuildVersion()}')"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
|
||||||
|
|
||||||
- name: Build TypeScript
|
- name: Run real pcbnew smoke tests
|
||||||
run: npm run build
|
env:
|
||||||
|
KICAD_USE_REAL_PCBNEW: "1"
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
run: |
|
||||||
echo "Integration tests not yet configured"
|
export PYTHONPATH="$KICAD_PYTHONPATH:$PYTHONPATH"
|
||||||
# pytest tests/integration/
|
pytest tests/test_real_pcbnew_matrix.py -m "integration and real_pcbnew"
|
||||||
|
|
||||||
# Docker build test
|
# Docker build test
|
||||||
docker-build:
|
docker-build:
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
- **TypeScript test scaffolding (Vitest)**: `npm run test:ts` now runs a real
|
||||||
|
Vitest suite instead of the placeholder echo. Starter coverage lives in
|
||||||
|
`tests-ts/` and exercises the pure-function modules `src/tools/registry.ts`
|
||||||
|
(categories, direct vs. routed classification, search, and stats invariants)
|
||||||
|
and `src/tools/tool-response.ts` (`formatKicadResult` success/error shaping).
|
||||||
|
Use `npm run test:ts:watch` for the watch-mode REPL. The CI `typescript-tests`
|
||||||
|
job now invokes the suite directly instead of swallowing failures.
|
||||||
|
|
||||||
|
- **Version sync**: `package.json` now reports `2.2.3`, matching the released
|
||||||
|
version documented in this changelog and in `docs/ROADMAP.md`. Previously it
|
||||||
|
was stuck at `2.1.0-alpha`.
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- **`rotate_component` now treats `angle` as an absolute target rotation**,
|
- **`rotate_component` now treats `angle` as an absolute target rotation**,
|
||||||
@@ -65,7 +79,6 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
|||||||
copper — this implementation explicitly walks all layers.
|
copper — this implementation explicitly walks all layers.
|
||||||
|
|
||||||
Combines three placement strategies, freely composable:
|
Combines three placement strategies, freely composable:
|
||||||
|
|
||||||
- `grid` — regular grid across the board interior.
|
- `grid` — regular grid across the board interior.
|
||||||
- `around_refs` — densify around named footprints (good for tucking
|
- `around_refs` — densify around named footprints (good for tucking
|
||||||
extra ground under MCUs, switching regulators, or RF parts).
|
extra ground under MCUs, switching regulators, or RF parts).
|
||||||
@@ -77,12 +90,12 @@ All notable changes to the KiCAD MCP Server project are documented here.
|
|||||||
`clearance`, `edgeMargin`), an `maxVias` cap for incremental work,
|
`clearance`, `edgeMargin`), an `maxVias` cap for incremental work,
|
||||||
auto-detection of the GND net (tries `GND` / `GROUND` / `VSS` /
|
auto-detection of the GND net (tries `GND` / `GROUND` / `VSS` /
|
||||||
`/GND`), and a `dryRun` mode that returns the placements that
|
`/GND`), and a `dryRun` mode that returns the placements that
|
||||||
*would* be made without modifying the board — useful for previewing
|
_would_ be made without modifying the board — useful for previewing
|
||||||
before committing.
|
before committing.
|
||||||
|
|
||||||
Returns `{ placed: [{x, y, unit}, ...], summary: {placed_count,
|
Returns `{ placed: [{x, y, unit}, ...], summary: {placed_count,
|
||||||
candidates_evaluated, skipped_by_zone_membership,
|
candidates_evaluated, skipped_by_zone_membership,
|
||||||
skipped_by_collision, ...} }`.
|
skipped_by_collision, ...} }`.
|
||||||
|
|
||||||
Approach ported from
|
Approach ported from
|
||||||
[morningfire-pcb-automation](https://github.com/NiNjA-CodE/morningfire-pcb-automation)
|
[morningfire-pcb-automation](https://github.com/NiNjA-CodE/morningfire-pcb-automation)
|
||||||
|
|||||||
@@ -250,8 +250,20 @@ Then create a Pull Request on GitHub.
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
The project has two test suites:
|
||||||
|
|
||||||
|
- **TypeScript** (Vitest) — lives in `tests-ts/`, covers the MCP protocol/router layer in `src/`.
|
||||||
|
- **Python** (pytest) — lives in `tests/`, covers the KiCAD interface in `python/`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests
|
# TypeScript tests (Vitest)
|
||||||
|
npm run test:ts # one-shot run
|
||||||
|
npm run test:ts:watch # watch mode
|
||||||
|
|
||||||
|
# Run both TS and Python suites
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Python tests only
|
||||||
pytest
|
pytest
|
||||||
|
|
||||||
# Unit tests only
|
# Unit tests only
|
||||||
|
|||||||
1420
package-lock.json
generated
1420
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kicad-mcp",
|
"name": "kicad-mcp",
|
||||||
"version": "2.1.0-alpha",
|
"version": "2.2.3",
|
||||||
"description": "AI-assisted PCB design with KiCAD via Model Context Protocol",
|
"description": "AI-assisted PCB design with KiCAD via Model Context Protocol",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"rebuild": "npm run clean && npm run build",
|
"rebuild": "npm run clean && npm run build",
|
||||||
"test": "npm run test:ts && npm run test:py",
|
"test": "npm run test:ts && npm run test:py",
|
||||||
"test:ts": "echo 'TypeScript tests not yet configured'",
|
"test:ts": "vitest run",
|
||||||
|
"test:ts:watch": "vitest",
|
||||||
"test:py": "pytest tests/ -v",
|
"test:py": "pytest tests/ -v",
|
||||||
"test:coverage": "pytest tests/ --cov=python --cov-report=html --cov-report=term",
|
"test:coverage": "pytest tests/ --cov=python --cov-report=html --cov-report=term",
|
||||||
"lint": "npm run lint:ts && npm run lint:py",
|
"lint": "npm run lint:ts && npm run lint:py",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.57.2"
|
"typescript-eslint": "^8.57.2",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ module = [
|
|||||||
"cairosvg",
|
"cairosvg",
|
||||||
"sexpdata",
|
"sexpdata",
|
||||||
"skip",
|
"skip",
|
||||||
|
"fitz",
|
||||||
"kipy",
|
"kipy",
|
||||||
"kipy.*",
|
"kipy.*",
|
||||||
"schematic",
|
"schematic",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ addopts =
|
|||||||
markers =
|
markers =
|
||||||
unit: Unit tests (fast, no external dependencies)
|
unit: Unit tests (fast, no external dependencies)
|
||||||
integration: Integration tests (requires KiCAD)
|
integration: Integration tests (requires KiCAD)
|
||||||
|
real_pcbnew: Headless tests that exercise real KiCad SWIG bindings
|
||||||
slow: Slow-running tests
|
slow: Slow-running tests
|
||||||
linux: Linux-specific tests
|
linux: Linux-specific tests
|
||||||
windows: Windows-specific tests
|
windows: Windows-specific tests
|
||||||
|
|||||||
@@ -198,6 +198,14 @@ class BoardViewCommands:
|
|||||||
output_svg,
|
output_svg,
|
||||||
"--mode-single",
|
"--mode-single",
|
||||||
"--black-and-white",
|
"--black-and-white",
|
||||||
|
# Render the board, not the drawing sheet. kicad-cli's
|
||||||
|
# default (page-size-mode 0) plots only the area inside the
|
||||||
|
# sheet, so board geometry past the page edge is left out.
|
||||||
|
# Mode 2 sizes the output to the board's bounding box;
|
||||||
|
# --exclude-drawing-sheet drops the title block.
|
||||||
|
"--exclude-drawing-sheet",
|
||||||
|
"--page-size-mode",
|
||||||
|
"2",
|
||||||
]
|
]
|
||||||
if layers:
|
if layers:
|
||||||
cmd += ["--layers", ",".join(layers)]
|
cmd += ["--layers", ",".join(layers)]
|
||||||
|
|||||||
@@ -390,6 +390,104 @@ class ComponentCommands:
|
|||||||
logger.error(f"Error editing component: {str(e)}")
|
logger.error(f"Error editing component: {str(e)}")
|
||||||
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
|
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
|
||||||
|
|
||||||
|
def set_footprint_type(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Set the placement type and exclusion flags of an existing PCB footprint.
|
||||||
|
|
||||||
|
The placement type controls whether a footprint appears in pick-and-place
|
||||||
|
(.pos) output files. KiCAD recognises three types:
|
||||||
|
- ``through_hole`` — PTH component (through-hole)
|
||||||
|
- ``smd`` — SMD component (surface-mount)
|
||||||
|
- ``unspecified`` — neither bit set (default for manually-placed items)
|
||||||
|
|
||||||
|
Optional exclusion flags (omit to leave unchanged):
|
||||||
|
- ``exclude_from_pos_files`` — suppress this ref from .pos exports
|
||||||
|
- ``exclude_from_bom`` — suppress this ref from BoM exports
|
||||||
|
- ``not_in_schematic`` — marks footprint as board-only (no schematic
|
||||||
|
symbol); KiCAD stores this as FP_BOARD_ONLY
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
reference = params.get("reference")
|
||||||
|
fp_type = params.get("type")
|
||||||
|
|
||||||
|
if not reference:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing reference",
|
||||||
|
"errorDetails": "reference parameter is required",
|
||||||
|
}
|
||||||
|
if fp_type not in ("smd", "through_hole", "unspecified"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Invalid type",
|
||||||
|
"errorDetails": "type must be one of: smd, through_hole, unspecified",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the footprint
|
||||||
|
module = self.board.FindFootprintByReference(reference)
|
||||||
|
if not module:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Component not found",
|
||||||
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Placement type (mutually exclusive bits) ---
|
||||||
|
# Read current attributes, clear both type bits, then set the new one.
|
||||||
|
attrs = module.GetAttributes()
|
||||||
|
attrs &= ~(pcbnew.FP_THROUGH_HOLE | pcbnew.FP_SMD)
|
||||||
|
if fp_type == "through_hole":
|
||||||
|
attrs |= pcbnew.FP_THROUGH_HOLE
|
||||||
|
elif fp_type == "smd":
|
||||||
|
attrs |= pcbnew.FP_SMD
|
||||||
|
# "unspecified" leaves both bits clear
|
||||||
|
module.SetAttributes(attrs)
|
||||||
|
|
||||||
|
# --- Optional exclusion flags ---
|
||||||
|
# Use the dedicated setters so we don't have to manage bit masks manually.
|
||||||
|
if "exclude_from_pos_files" in params:
|
||||||
|
module.SetExcludedFromPosFiles(bool(params["exclude_from_pos_files"]))
|
||||||
|
if "exclude_from_bom" in params:
|
||||||
|
module.SetExcludedFromBOM(bool(params["exclude_from_bom"]))
|
||||||
|
if "not_in_schematic" in params:
|
||||||
|
# FP_BOARD_ONLY is the underlying bit for the "not in schematic" flag.
|
||||||
|
module.SetBoardOnly(bool(params["not_in_schematic"]))
|
||||||
|
|
||||||
|
# Read back the final state so the caller can verify.
|
||||||
|
final_attrs = module.GetAttributes()
|
||||||
|
if final_attrs & pcbnew.FP_THROUGH_HOLE:
|
||||||
|
resolved_type = "through_hole"
|
||||||
|
elif final_attrs & pcbnew.FP_SMD:
|
||||||
|
resolved_type = "smd"
|
||||||
|
else:
|
||||||
|
resolved_type = "unspecified"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Updated footprint type for {reference}",
|
||||||
|
"component": {
|
||||||
|
"reference": reference,
|
||||||
|
"type": resolved_type,
|
||||||
|
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
|
||||||
|
"exclude_from_bom": module.IsExcludedFromBOM(),
|
||||||
|
"not_in_schematic": module.IsBoardOnly(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting footprint type: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to set footprint type",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get detailed properties of a component"""
|
"""Get detailed properties of a component"""
|
||||||
try:
|
try:
|
||||||
@@ -454,6 +552,15 @@ class ComponentCommands:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Courtyard may not exist or API may differ
|
pass # Courtyard may not exist or API may differ
|
||||||
|
|
||||||
|
# Resolve placement type as a human-readable string
|
||||||
|
raw_attrs = module.GetAttributes()
|
||||||
|
if raw_attrs & pcbnew.FP_THROUGH_HOLE:
|
||||||
|
placement_type = "through_hole"
|
||||||
|
elif raw_attrs & pcbnew.FP_SMD:
|
||||||
|
placement_type = "smd"
|
||||||
|
else:
|
||||||
|
placement_type = "unspecified"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"component": {
|
"component": {
|
||||||
@@ -464,9 +571,10 @@ class ComponentCommands:
|
|||||||
"rotation": module.GetOrientation().AsDegrees(),
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"layer": self.board.GetLayerName(module.GetLayer()),
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
"type": placement_type,
|
||||||
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
|
||||||
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
|
"exclude_from_bom": module.IsExcludedFromBOM(),
|
||||||
|
"not_in_schematic": module.IsBoardOnly(),
|
||||||
},
|
},
|
||||||
"boundingBox": bbox_data,
|
"boundingBox": bbox_data,
|
||||||
"courtyard": courtyard_data,
|
"courtyard": courtyard_data,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class DynamicSymbolLoader:
|
|||||||
possible_paths = [
|
possible_paths = [
|
||||||
Path("/usr/share/kicad/symbols"),
|
Path("/usr/share/kicad/symbols"),
|
||||||
Path("/usr/local/share/kicad/symbols"),
|
Path("/usr/local/share/kicad/symbols"),
|
||||||
|
Path("C:/Program Files/KiCad/10.0/share/kicad/symbols"),
|
||||||
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
|
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
|
||||||
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
|
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
|
||||||
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
|
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
|
||||||
@@ -477,16 +478,18 @@ class DynamicSymbolLoader:
|
|||||||
# Iterate over every top-level (property ...) block in the symbol
|
# Iterate over every top-level (property ...) block in the symbol
|
||||||
search_pos = 0
|
search_pos = 0
|
||||||
while True:
|
while True:
|
||||||
m = re.search(r'\(property\s+"([^"]+)"\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)',
|
m = re.search(
|
||||||
sym_block[search_pos:])
|
r'\(property\s+"([^"]+)"\s+"[^"]*"\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)',
|
||||||
|
sym_block[search_pos:],
|
||||||
|
)
|
||||||
if not m:
|
if not m:
|
||||||
break
|
break
|
||||||
abs_start = search_pos + m.start()
|
abs_start = search_pos + m.start()
|
||||||
prop_block = self._extract_paren_block(sym_block, abs_start)
|
prop_block = self._extract_paren_block(sym_block, abs_start)
|
||||||
|
|
||||||
name = m.group(1)
|
name = m.group(1)
|
||||||
dx = float(m.group(2))
|
dx = float(m.group(2))
|
||||||
dy = float(m.group(3))
|
dy = float(m.group(3))
|
||||||
angle = float(m.group(4))
|
angle = float(m.group(4))
|
||||||
|
|
||||||
# Extract (effects ...) block from within this property
|
# Extract (effects ...) block from within this property
|
||||||
@@ -495,7 +498,7 @@ class DynamicSymbolLoader:
|
|||||||
effects_str = self._extract_paren_block(prop_block, eff_pos)
|
effects_str = self._extract_paren_block(prop_block, eff_pos)
|
||||||
# Strip (hide ...) sub-expressions — visibility will be set
|
# Strip (hide ...) sub-expressions — visibility will be set
|
||||||
# separately by the caller
|
# separately by the caller
|
||||||
effects_str = re.sub(r'\s*\(hide\s+[^)]+\)', '', effects_str)
|
effects_str = re.sub(r"\s*\(hide\s+[^)]+\)", "", effects_str)
|
||||||
effects_str = effects_str.strip()
|
effects_str = effects_str.strip()
|
||||||
else:
|
else:
|
||||||
effects_str = "(effects (font (size 1.27 1.27)))"
|
effects_str = "(effects (font (size 1.27 1.27)))"
|
||||||
@@ -556,14 +559,13 @@ class DynamicSymbolLoader:
|
|||||||
new_uuid = str(uuid.uuid4())
|
new_uuid = str(uuid.uuid4())
|
||||||
|
|
||||||
# --- read property offsets from the already-injected lib_symbols block -----
|
# --- read property offsets from the already-injected lib_symbols block -----
|
||||||
lib_props = self._extract_lib_property_positions(
|
lib_props = self._extract_lib_property_positions(schematic_path, library_name, symbol_name)
|
||||||
schematic_path, library_name, symbol_name
|
|
||||||
)
|
|
||||||
|
|
||||||
_DEFAULT_EFFECTS = "(effects (font (size 1.27 1.27)))"
|
_DEFAULT_EFFECTS = "(effects (font (size 1.27 1.27)))"
|
||||||
|
|
||||||
def _prop_at(name: str, fallback_dx: float, fallback_dy: float,
|
def _prop_at(
|
||||||
fallback_angle: float = 0) -> tuple:
|
name: str, fallback_dx: float, fallback_dy: float, fallback_angle: float = 0
|
||||||
|
) -> tuple:
|
||||||
"""Return (abs_x, abs_y, text_angle, effects_str) for a property."""
|
"""Return (abs_x, abs_y, text_angle, effects_str) for a property."""
|
||||||
if name in lib_props:
|
if name in lib_props:
|
||||||
dx, dy, text_ang, eff = lib_props[name]
|
dx, dy, text_ang, eff = lib_props[name]
|
||||||
@@ -572,10 +574,10 @@ class DynamicSymbolLoader:
|
|||||||
rdx, rdy = self._rotate_offset(dx, dy, angle)
|
rdx, rdy = self._rotate_offset(dx, dy, angle)
|
||||||
return round(x + rdx, 3), round(y + rdy, 3), text_ang, eff
|
return round(x + rdx, 3), round(y + rdy, 3), text_ang, eff
|
||||||
|
|
||||||
ref_x, ref_y, ref_a, ref_eff = _prop_at("Reference", 2.032, 0, 0)
|
ref_x, ref_y, ref_a, ref_eff = _prop_at("Reference", 2.032, 0, 0)
|
||||||
val_x, val_y, val_a, val_eff = _prop_at("Value", 0, 2.54, 0)
|
val_x, val_y, val_a, val_eff = _prop_at("Value", 0, 2.54, 0)
|
||||||
fp_x, fp_y, _, _ = _prop_at("Footprint", 0, 0, 0)
|
fp_x, fp_y, _, _ = _prop_at("Footprint", 0, 0, 0)
|
||||||
ds_x, ds_y, _, _ = _prop_at("Datasheet", 0, 0, 0)
|
ds_x, ds_y, _, _ = _prop_at("Datasheet", 0, 0, 0)
|
||||||
|
|
||||||
mirror_str = " (mirror y)" if mirror_y else ""
|
mirror_str = " (mirror y)" if mirror_y else ""
|
||||||
instance_block = f""" (symbol (lib_id "{full_lib_id}") (at {x} {y} {angle}){mirror_str} (unit {unit})
|
instance_block = f""" (symbol (lib_id "{full_lib_id}") (at {x} {y} {angle}){mirror_str} (unit {unit})
|
||||||
|
|||||||
@@ -182,12 +182,15 @@ class LibraryManager:
|
|||||||
possible_paths = [
|
possible_paths = [
|
||||||
"/usr/share/kicad/footprints",
|
"/usr/share/kicad/footprints",
|
||||||
"/usr/local/share/kicad/footprints",
|
"/usr/local/share/kicad/footprints",
|
||||||
|
"C:/Program Files/KiCad/10.0/share/kicad/footprints",
|
||||||
"C:/Program Files/KiCad/9.0/share/kicad/footprints",
|
"C:/Program Files/KiCad/9.0/share/kicad/footprints",
|
||||||
"C:/Program Files/KiCad/8.0/share/kicad/footprints",
|
"C:/Program Files/KiCad/8.0/share/kicad/footprints",
|
||||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
|
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Also check environment variable
|
# Also check environment variable
|
||||||
|
if "KICAD10_FOOTPRINT_DIR" in os.environ:
|
||||||
|
possible_paths.insert(0, os.environ["KICAD10_FOOTPRINT_DIR"])
|
||||||
if "KICAD9_FOOTPRINT_DIR" in os.environ:
|
if "KICAD9_FOOTPRINT_DIR" in os.environ:
|
||||||
possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
|
possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
|
||||||
if "KICAD8_FOOTPRINT_DIR" in os.environ:
|
if "KICAD8_FOOTPRINT_DIR" in os.environ:
|
||||||
|
|||||||
@@ -83,9 +83,7 @@ class SymbolLibraryManager:
|
|||||||
logger.debug("Skipping unparseable library: %s", nickname)
|
logger.debug("Skipping unparseable library: %s", nickname)
|
||||||
logger.info("Symbol cache warm-up complete (%d libraries)", len(self.libraries))
|
logger.info("Symbol cache warm-up complete (%d libraries)", len(self.libraries))
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(target=_warm, name="symbol-cache-warmup", daemon=True).start()
|
||||||
target=_warm, name="symbol-cache-warmup", daemon=True
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def _load_libraries(self) -> None:
|
def _load_libraries(self) -> None:
|
||||||
"""Load libraries from sym-lib-table files"""
|
"""Load libraries from sym-lib-table files"""
|
||||||
@@ -232,12 +230,15 @@ class SymbolLibraryManager:
|
|||||||
possible_paths = [
|
possible_paths = [
|
||||||
"/usr/share/kicad/symbols",
|
"/usr/share/kicad/symbols",
|
||||||
"/usr/local/share/kicad/symbols",
|
"/usr/local/share/kicad/symbols",
|
||||||
|
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
|
||||||
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
|
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
|
||||||
"C:/Program Files/KiCad/8.0/share/kicad/symbols",
|
"C:/Program Files/KiCad/8.0/share/kicad/symbols",
|
||||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
|
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check environment variable
|
# Check environment variable
|
||||||
|
if "KICAD10_SYMBOL_DIR" in os.environ:
|
||||||
|
possible_paths.insert(0, os.environ["KICAD10_SYMBOL_DIR"])
|
||||||
if "KICAD9_SYMBOL_DIR" in os.environ:
|
if "KICAD9_SYMBOL_DIR" in os.environ:
|
||||||
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
|
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
|
||||||
if "KICAD8_SYMBOL_DIR" in os.environ:
|
if "KICAD8_SYMBOL_DIR" in os.environ:
|
||||||
@@ -610,8 +611,10 @@ class SymbolLibraryCommands:
|
|||||||
# Patch (SER2RJ45): keep cache when project_path matches AND libraries were
|
# Patch (SER2RJ45): keep cache when project_path matches AND libraries were
|
||||||
# actually loaded. Original early-return skipped rebuild even when the cache
|
# actually loaded. Original early-return skipped rebuild even when the cache
|
||||||
# was empty (e.g. first call after create_project, before sym-lib-table existed).
|
# was empty (e.g. first call after create_project, before sym-lib-table existed).
|
||||||
if (self.library_manager.project_path == project_path
|
if (
|
||||||
and len(self.library_manager.libraries) > 0):
|
self.library_manager.project_path == project_path
|
||||||
|
and len(self.library_manager.libraries) > 0
|
||||||
|
):
|
||||||
return
|
return
|
||||||
logger.info(f"Rebuilding SymbolLibraryManager for project: {project_path}")
|
logger.info(f"Rebuilding SymbolLibraryManager for project: {project_path}")
|
||||||
self.library_manager = SymbolLibraryManager(project_path=project_path)
|
self.library_manager = SymbolLibraryManager(project_path=project_path)
|
||||||
|
|||||||
@@ -143,7 +143,14 @@ class PinLocator:
|
|||||||
# KiCad lib_symbols may use a different name than the instance lib_id:
|
# KiCad lib_symbols may use a different name than the instance lib_id:
|
||||||
# instance lib_id: "stat-tis-custom:BAT_18650"
|
# instance lib_id: "stat-tis-custom:BAT_18650"
|
||||||
# lib_symbols name: "BAT_18650_3" (prefix stripped, unit suffix added)
|
# lib_symbols name: "BAT_18650_3" (prefix stripped, unit suffix added)
|
||||||
# Strategy: exact match first, then bare-name prefix match.
|
# instance lib_id: "Regulator_Linear:AMS1117-3.3"
|
||||||
|
# lib_symbols name: "AMS1117-3.3_0_1" (multi-unit symbol)
|
||||||
|
#
|
||||||
|
# Strategy (in priority order):
|
||||||
|
# 1. Exact match on full lib_id
|
||||||
|
# 2. Bare name exact match (strip library prefix from both sides)
|
||||||
|
# 3. Bare name prefix match with unit suffix (_N)
|
||||||
|
# 4. Substring match (lib_id bare name appears inside symbol_name)
|
||||||
bare_name = lib_id.split(":")[-1] if ":" in lib_id else lib_id
|
bare_name = lib_id.split(":")[-1] if ":" in lib_id else lib_id
|
||||||
|
|
||||||
best_match = None
|
best_match = None
|
||||||
@@ -151,18 +158,31 @@ class PinLocator:
|
|||||||
if not (isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol")):
|
if not (isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol")):
|
||||||
continue
|
continue
|
||||||
symbol_name = str(item[1]).strip('"')
|
symbol_name = str(item[1]).strip('"')
|
||||||
|
sn_bare = symbol_name.split(":")[-1] if ":" in symbol_name else symbol_name
|
||||||
|
|
||||||
|
# Strategy 1: Exact full lib_id match
|
||||||
if symbol_name == lib_id:
|
if symbol_name == lib_id:
|
||||||
best_match = item
|
best_match = item
|
||||||
break
|
break
|
||||||
if best_match is None:
|
|
||||||
sn_bare = symbol_name.split(":")[-1] if ":" in symbol_name else symbol_name
|
# Strategy 2: Bare name exact match
|
||||||
if sn_bare == bare_name or (
|
if best_match is None and sn_bare == bare_name:
|
||||||
sn_bare.startswith(bare_name)
|
best_match = item
|
||||||
and len(sn_bare) > len(bare_name)
|
|
||||||
and sn_bare[len(bare_name)] == "_"
|
# Strategy 3: Bare name prefix match with unit suffix (_N)
|
||||||
and sn_bare[len(bare_name) + 1 :].isdigit()
|
if best_match is None and (
|
||||||
):
|
sn_bare.startswith(bare_name)
|
||||||
best_match = item
|
and len(sn_bare) > len(bare_name)
|
||||||
|
and sn_bare[len(bare_name)] == "_"
|
||||||
|
and sn_bare[len(bare_name) + 1 :].isdigit()
|
||||||
|
):
|
||||||
|
best_match = item
|
||||||
|
|
||||||
|
# Strategy 4: Substring match — lib_id bare name appears inside symbol_name
|
||||||
|
# (handles cases like "AMS1117-3.3" matching "AMS1117-3.3_0_1" or
|
||||||
|
# names with extra KiCad-generated suffixes beyond unit numbers)
|
||||||
|
if best_match is None and bare_name in sn_bare:
|
||||||
|
best_match = item
|
||||||
|
|
||||||
if best_match is not None:
|
if best_match is not None:
|
||||||
matched_name = str(best_match[1]).strip('"')
|
matched_name = str(best_match[1]).strip('"')
|
||||||
@@ -176,8 +196,17 @@ class PinLocator:
|
|||||||
logger.info(f"Extracted {len(pins)} pins from {lib_id}")
|
logger.info(f"Extracted {len(pins)} pins from {lib_id}")
|
||||||
return pins
|
return pins
|
||||||
|
|
||||||
logger.warning(f"Symbol {lib_id} not found in lib_symbols")
|
# When no match is found, list available symbol names for debugging
|
||||||
return {}
|
if best_match is None:
|
||||||
|
available = []
|
||||||
|
for item in lib_symbols[1:]:
|
||||||
|
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
|
||||||
|
available.append(str(item[1]).strip('"'))
|
||||||
|
logger.warning(
|
||||||
|
f"Symbol {lib_id} (bare: '{bare_name}') not found in lib_symbols. "
|
||||||
|
f"Available ({len(available)}): {available[:10]}{'...' if len(available) > 10 else ''}"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting symbol pins: {e}")
|
logger.error(f"Error getting symbol pins: {e}")
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
Routing-related command implementations for KiCAD interface
|
Routing-related command implementations for KiCAD interface
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import pcbnew
|
import pcbnew
|
||||||
@@ -12,6 +15,128 @@ import pcbnew
|
|||||||
logger = logging.getLogger("kicad_interface")
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Net class project-file persistence (KiCad 7+) ------------------------
|
||||||
|
#
|
||||||
|
# Net class *definitions* live in the project file (``<project>.kicad_pro`` ->
|
||||||
|
# ``net_settings``), not in the ``.kicad_pcb`` board file, since KiCad 7. The
|
||||||
|
# SWIG board save only writes ``.kicad_pcb``, so a NETCLASS created on the
|
||||||
|
# in-memory board never survives a reload on its own (issue #185). These
|
||||||
|
# helpers write the class definition straight into the project JSON, which is
|
||||||
|
# what KiCad reads on open. Values are millimetres (the
|
||||||
|
# ``.kicad_pro`` unit). They are pure module functions so they can be unit
|
||||||
|
# tested without a live KiCad / SWIG round-trip.
|
||||||
|
|
||||||
|
# net_settings.classes numeric fields this tool can set (millimetres). The
|
||||||
|
# caller passes values already keyed by these names, so the persisted class
|
||||||
|
# cannot drift from differing request-key spellings (e.g. traceWidth).
|
||||||
|
_NETCLASS_NUMERIC_FIELDS = (
|
||||||
|
"clearance",
|
||||||
|
"track_width",
|
||||||
|
"via_diameter",
|
||||||
|
"via_drill",
|
||||||
|
"microvia_diameter",
|
||||||
|
"microvia_drill",
|
||||||
|
"diff_pair_width",
|
||||||
|
"diff_pair_gap",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback field set (KiCad 10 defaults) used only when the project has no
|
||||||
|
# "Default" class to clone the shape from.
|
||||||
|
_DEFAULT_NETCLASS_TEMPLATE = {
|
||||||
|
"bus_width": 12,
|
||||||
|
"clearance": 0.2,
|
||||||
|
"diff_pair_gap": 0.25,
|
||||||
|
"diff_pair_via_gap": 0.25,
|
||||||
|
"diff_pair_width": 0.2,
|
||||||
|
"line_style": 0,
|
||||||
|
"microvia_diameter": 0.3,
|
||||||
|
"microvia_drill": 0.1,
|
||||||
|
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"priority": 0,
|
||||||
|
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"track_width": 0.2,
|
||||||
|
"tuning_profile": "",
|
||||||
|
"via_diameter": 0.6,
|
||||||
|
"via_drill": 0.3,
|
||||||
|
"wire_width": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_netclass_to_project_settings(
|
||||||
|
data: Dict[str, Any], name: str, props: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Insert or update a net class *definition* in a parsed ``.kicad_pro`` dict.
|
||||||
|
Pure: mutates and returns ``data``; performs no I/O.
|
||||||
|
|
||||||
|
Net class definitions live in the project file's ``net_settings.classes`` on
|
||||||
|
KiCad 7+. ``props`` is keyed by ``net_settings.classes`` field names
|
||||||
|
(``clearance``/``track_width``/...) in millimetres; the caller normalizes the
|
||||||
|
request keys so the persisted class never diverges from the live board. A
|
||||||
|
new class is cloned from the project's ``Default`` class (falling back to a
|
||||||
|
built-in template) so it carries KiCad's full field set.
|
||||||
|
"""
|
||||||
|
net_settings = data.setdefault("net_settings", {})
|
||||||
|
classes = net_settings.setdefault("classes", [])
|
||||||
|
|
||||||
|
cls = next((c for c in classes if c.get("name") == name), None)
|
||||||
|
if cls is None:
|
||||||
|
template = next((c for c in classes if c.get("name") == "Default"), None)
|
||||||
|
cls = dict(template) if template else dict(_DEFAULT_NETCLASS_TEMPLATE)
|
||||||
|
cls["name"] = name
|
||||||
|
cls["priority"] = 0 # custom classes; the Default class keeps its own priority
|
||||||
|
classes.append(cls)
|
||||||
|
|
||||||
|
for key in _NETCLASS_NUMERIC_FIELDS:
|
||||||
|
value = props.get(key)
|
||||||
|
if value is not None:
|
||||||
|
cls[key] = float(value)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def persist_netclass_to_project(
|
||||||
|
pro_path: Optional[str], name: str, props: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Read/modify/write ``pro_path`` so the net class definition survives a
|
||||||
|
reload (KiCad 7+ keeps it in the project file, not the board).
|
||||||
|
|
||||||
|
Returns ``{"persisted": bool, "projectFile"?: str, "warning"?: str}``. Never
|
||||||
|
raises: a persistence failure is reported, not fatal, so the in-memory net
|
||||||
|
class still stands. The write is atomic (temp file + ``os.replace``) so a
|
||||||
|
crash mid-write cannot corrupt the project file.
|
||||||
|
"""
|
||||||
|
if not pro_path or not os.path.exists(pro_path):
|
||||||
|
return {
|
||||||
|
"persisted": False,
|
||||||
|
"warning": "no .kicad_pro project file found; net class set in memory "
|
||||||
|
"only and will not persist across a reload",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(pro_path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
apply_netclass_to_project_settings(data, name, props)
|
||||||
|
|
||||||
|
directory = os.path.dirname(pro_path) or "."
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".netclass-", suffix=".kicad_pro")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(data, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
os.replace(tmp_path, pro_path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return {"persisted": True, "projectFile": pro_path}
|
||||||
|
except Exception as exc: # report, never fail the command on a persistence error
|
||||||
|
return {
|
||||||
|
"persisted": False,
|
||||||
|
"warning": f"could not persist net class to {pro_path}: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RoutingCommands:
|
class RoutingCommands:
|
||||||
"""Handles routing-related KiCAD operations"""
|
"""Handles routing-related KiCAD operations"""
|
||||||
|
|
||||||
@@ -1262,76 +1387,88 @@ class RoutingCommands:
|
|||||||
"errorDetails": "name parameter is required",
|
"errorDetails": "name parameter is required",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get net classes — KiCad 6/7 returns NETCLASSES with .Find/.Add;
|
# Net class DEFINITIONS live in <project>.kicad_pro (net_settings) on
|
||||||
# KiCad 9/10 returns a netclasses_map (SWIG-wrapped std::map) that is dict-like.
|
# KiCad 7+, not in the .kicad_pcb the SWIG board save writes. Resolve
|
||||||
net_classes = self.board.GetNetClasses()
|
# the project file up front so the durable write below runs even if a
|
||||||
|
# SWIG API (which shifted across KiCad 6->10) throws (issue #185).
|
||||||
|
pro_path = None
|
||||||
|
try:
|
||||||
|
board_path = self.board.GetFileName()
|
||||||
|
if board_path and board_path.endswith(".kicad_pcb"):
|
||||||
|
pro_path = str(Path(board_path).with_suffix(".kicad_pro"))
|
||||||
|
except Exception:
|
||||||
|
pro_path = None
|
||||||
|
|
||||||
existing = None
|
|
||||||
if hasattr(net_classes, "Find"):
|
|
||||||
existing = net_classes.Find(name)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
if name in net_classes:
|
|
||||||
existing = net_classes[name]
|
|
||||||
except Exception:
|
|
||||||
existing = None
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
netclass = pcbnew.NETCLASS(name)
|
|
||||||
if hasattr(net_classes, "Add"):
|
|
||||||
net_classes.Add(netclass)
|
|
||||||
else:
|
|
||||||
net_classes[name] = netclass
|
|
||||||
else:
|
|
||||||
netclass = existing
|
|
||||||
|
|
||||||
# Set properties
|
|
||||||
scale = 1000000 # mm to nm
|
scale = 1000000 # mm to nm
|
||||||
|
net_class_values: Dict[str, Any] = {}
|
||||||
|
in_memory_warning = None
|
||||||
|
|
||||||
# Defensive setters — KiCad 10's NETCLASS dropped some legacy mutators.
|
# Best-effort in-memory NETCLASS for the live session. A SWIG failure
|
||||||
def _safe_set(method_name, value):
|
# here is logged but must NOT skip the .kicad_pro persistence below,
|
||||||
if value is None:
|
# which is the deterministic, SWIG-independent part (issue #185).
|
||||||
return
|
try:
|
||||||
method = getattr(netclass, method_name, None)
|
# KiCad 6/7 returns NETCLASSES with .Find/.Add; KiCad 9/10 returns
|
||||||
if method is None:
|
# a netclasses_map (SWIG-wrapped std::map) that is dict-like.
|
||||||
return
|
net_classes = self.board.GetNetClasses()
|
||||||
try:
|
|
||||||
method(int(value * scale))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
_safe_set("SetClearance", clearance)
|
existing = None
|
||||||
_safe_set("SetTrackWidth", track_width)
|
if hasattr(net_classes, "Find"):
|
||||||
_safe_set("SetViaDiameter", via_diameter)
|
existing = net_classes.Find(name)
|
||||||
_safe_set("SetViaDrill", via_drill)
|
else:
|
||||||
_safe_set("SetMicroViaDiameter", uvia_diameter)
|
try:
|
||||||
_safe_set("SetMicroViaDrill", uvia_drill)
|
if name in net_classes:
|
||||||
_safe_set("SetDiffPairWidth", diff_pair_width)
|
existing = net_classes[name]
|
||||||
_safe_set("SetDiffPairGap", diff_pair_gap)
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
|
||||||
# Add nets to net class
|
if existing is None:
|
||||||
netinfo = self.board.GetNetInfo()
|
netclass = pcbnew.NETCLASS(name)
|
||||||
nets_map = netinfo.NetsByName()
|
if hasattr(net_classes, "Add"):
|
||||||
for net_name in nets:
|
net_classes.Add(netclass)
|
||||||
if nets_map.has_key(net_name):
|
else:
|
||||||
net = nets_map[net_name]
|
net_classes[name] = netclass
|
||||||
net.SetClass(netclass)
|
else:
|
||||||
|
netclass = existing
|
||||||
|
|
||||||
# Defensive accessors — KiCad 10's NETCLASS dropped some legacy getters.
|
# Defensive setters — KiCad 10's NETCLASS dropped some legacy mutators.
|
||||||
def _safe_get(method_name):
|
def _safe_set(method_name, value):
|
||||||
method = getattr(netclass, method_name, None)
|
if value is None:
|
||||||
if method is None:
|
return
|
||||||
return None
|
method = getattr(netclass, method_name, None)
|
||||||
try:
|
if method is None:
|
||||||
return method() / scale
|
return
|
||||||
except Exception:
|
try:
|
||||||
return None
|
method(int(value * scale))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
_safe_set("SetClearance", clearance)
|
||||||
"success": True,
|
_safe_set("SetTrackWidth", track_width)
|
||||||
"message": f"Created net class: {name}",
|
_safe_set("SetViaDiameter", via_diameter)
|
||||||
"netClass": {
|
_safe_set("SetViaDrill", via_drill)
|
||||||
"name": name,
|
_safe_set("SetMicroViaDiameter", uvia_diameter)
|
||||||
|
_safe_set("SetMicroViaDrill", uvia_drill)
|
||||||
|
_safe_set("SetDiffPairWidth", diff_pair_width)
|
||||||
|
_safe_set("SetDiffPairGap", diff_pair_gap)
|
||||||
|
|
||||||
|
netinfo = self.board.GetNetInfo()
|
||||||
|
nets_map = netinfo.NetsByName()
|
||||||
|
for net_name in nets:
|
||||||
|
if nets_map.has_key(net_name):
|
||||||
|
net = nets_map[net_name]
|
||||||
|
net.SetClass(netclass)
|
||||||
|
|
||||||
|
# Defensive accessors — KiCad 10's NETCLASS dropped some legacy getters.
|
||||||
|
def _safe_get(method_name):
|
||||||
|
method = getattr(netclass, method_name, None)
|
||||||
|
if method is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return method() / scale
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
net_class_values = {
|
||||||
"clearance": _safe_get("GetClearance"),
|
"clearance": _safe_get("GetClearance"),
|
||||||
"trackWidth": _safe_get("GetTrackWidth"),
|
"trackWidth": _safe_get("GetTrackWidth"),
|
||||||
"viaDiameter": _safe_get("GetViaDiameter"),
|
"viaDiameter": _safe_get("GetViaDiameter"),
|
||||||
@@ -1340,9 +1477,47 @@ class RoutingCommands:
|
|||||||
"uviaDrill": _safe_get("GetMicroViaDrill"),
|
"uviaDrill": _safe_get("GetMicroViaDrill"),
|
||||||
"diffPairWidth": _safe_get("GetDiffPairWidth"),
|
"diffPairWidth": _safe_get("GetDiffPairWidth"),
|
||||||
"diffPairGap": _safe_get("GetDiffPairGap"),
|
"diffPairGap": _safe_get("GetDiffPairGap"),
|
||||||
"nets": nets,
|
}
|
||||||
},
|
except Exception as exc:
|
||||||
|
in_memory_warning = "in-memory NETCLASS update failed: %s" % exc
|
||||||
|
logger.warning("create_netclass: %s", in_memory_warning)
|
||||||
|
|
||||||
|
# Persist the class DEFINITION (net_settings.classes) using the same
|
||||||
|
# normalized values as the live path, so the persisted class can never
|
||||||
|
# diverge from the request. Membership lives in netclass_patterns and
|
||||||
|
# is out of scope here (create_netclass exposes no `nets` field).
|
||||||
|
project_props = {
|
||||||
|
"clearance": clearance,
|
||||||
|
"track_width": track_width,
|
||||||
|
"via_diameter": via_diameter,
|
||||||
|
"via_drill": via_drill,
|
||||||
|
"microvia_diameter": uvia_diameter,
|
||||||
|
"microvia_drill": uvia_drill,
|
||||||
|
"diff_pair_width": diff_pair_width,
|
||||||
|
"diff_pair_gap": diff_pair_gap,
|
||||||
}
|
}
|
||||||
|
persist = persist_netclass_to_project(pro_path, name, project_props)
|
||||||
|
|
||||||
|
warnings = [w for w in (in_memory_warning, persist.get("warning")) if w]
|
||||||
|
if in_memory_warning and not persist.get("persisted"):
|
||||||
|
# Neither the live board nor the project file received the class.
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to create net class",
|
||||||
|
"errorDetails": "; ".join(warnings),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Created net class: {name}",
|
||||||
|
"netClass": {"name": name, "nets": nets, **net_class_values},
|
||||||
|
"persisted": persist.get("persisted", False),
|
||||||
|
}
|
||||||
|
if persist.get("projectFile"):
|
||||||
|
result["projectFile"] = persist["projectFile"]
|
||||||
|
if warnings:
|
||||||
|
result["warning"] = "; ".join(warnings)
|
||||||
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating net class: {str(e)}")
|
logger.error(f"Error creating net class: {str(e)}")
|
||||||
|
|||||||
3104
python/commands/schematic_handlers.py
Normal file
3104
python/commands/schematic_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,59 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// Set Footprint Type Tool
|
||||||
|
// ------------------------------------------------------
|
||||||
|
server.tool(
|
||||||
|
"set_footprint_type",
|
||||||
|
"Set the placement type (through_hole / smd / unspecified) and optional exclusion flags on a placed PCB footprint. The placement type controls whether the footprint is included in pick-and-place (.pos) output files. Use exclude_from_pos_files to suppress a footprint from .pos exports without changing its type.",
|
||||||
|
{
|
||||||
|
reference: z.string().describe("Reference designator of the footprint (e.g. 'R1', 'U3')"),
|
||||||
|
type: z
|
||||||
|
.enum(["smd", "through_hole", "unspecified"])
|
||||||
|
.describe(
|
||||||
|
"Placement type: 'smd' for surface-mount, 'through_hole' for PTH components, 'unspecified' to clear both bits (e.g. for board-only or mechanically-placed items)",
|
||||||
|
),
|
||||||
|
exclude_from_pos_files: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, suppress this footprint from pick-and-place (.pos) exports. Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
exclude_from_bom: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, suppress this footprint from BoM exports. Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
not_in_schematic: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, marks the footprint as board-only (no corresponding schematic symbol). Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async ({ reference, type, exclude_from_pos_files, exclude_from_bom, not_in_schematic }) => {
|
||||||
|
logger.debug(`Setting footprint type for: ${reference} -> ${type}`);
|
||||||
|
const result = await callKicadScript("set_footprint_type", {
|
||||||
|
reference,
|
||||||
|
type,
|
||||||
|
exclude_from_pos_files,
|
||||||
|
exclude_from_bom,
|
||||||
|
not_in_schematic,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Find Component Tool
|
// Find Component Tool
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
|
|||||||
1019
src/tools/export.ts
1019
src/tools/export.ts
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,25 @@ export const toolCategories: ToolCategory[] = [
|
|||||||
description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models",
|
description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models",
|
||||||
tools: [
|
tools: [
|
||||||
"export_gerber",
|
"export_gerber",
|
||||||
|
"export_gerbers",
|
||||||
|
"export_drill",
|
||||||
|
"export_ipc2581",
|
||||||
|
"export_odb",
|
||||||
|
"export_ipcd356",
|
||||||
|
"export_gencad",
|
||||||
|
"export_pos",
|
||||||
|
"export_pcb_pdf",
|
||||||
|
"export_pcb_svg",
|
||||||
|
"export_pcb_dxf",
|
||||||
|
"export_gerber_single",
|
||||||
|
"export_3d_cli",
|
||||||
|
"export_sch_bom",
|
||||||
|
"export_sch_pdf",
|
||||||
|
"export_sch_svg",
|
||||||
|
"export_sch_dxf",
|
||||||
|
"export_sch_hpgl",
|
||||||
|
"export_sch_ps",
|
||||||
|
"export_sch_python_bom",
|
||||||
"export_pdf",
|
"export_pdf",
|
||||||
"export_svg",
|
"export_svg",
|
||||||
"export_3d",
|
"export_3d",
|
||||||
|
|||||||
149
tests-ts/registry.test.ts
Normal file
149
tests-ts/registry.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
directToolNames,
|
||||||
|
getAllCategories,
|
||||||
|
getCategory,
|
||||||
|
getRegistryStats,
|
||||||
|
getRoutedToolNames,
|
||||||
|
getToolCategory,
|
||||||
|
isDirectTool,
|
||||||
|
isRoutedTool,
|
||||||
|
searchTools,
|
||||||
|
toolCategories,
|
||||||
|
} from "../src/tools/registry.js";
|
||||||
|
|
||||||
|
describe("tool registry — categories", () => {
|
||||||
|
it("getAllCategories returns the exported list", () => {
|
||||||
|
const categories = getAllCategories();
|
||||||
|
expect(categories).toBe(toolCategories);
|
||||||
|
expect(categories.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every category has a name, description, and at least one tool", () => {
|
||||||
|
for (const category of getAllCategories()) {
|
||||||
|
expect(category.name).toBeTruthy();
|
||||||
|
expect(category.description.length).toBeGreaterThan(0);
|
||||||
|
expect(category.tools.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCategory returns a known category", () => {
|
||||||
|
const schematic = getCategory("schematic");
|
||||||
|
expect(schematic).toBeDefined();
|
||||||
|
expect(schematic?.name).toBe("schematic");
|
||||||
|
expect(schematic?.tools).toContain("add_schematic_wire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCategory returns undefined for unknown names", () => {
|
||||||
|
expect(getCategory("nonexistent_category")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool registry — direct vs routed classification", () => {
|
||||||
|
it("isDirectTool identifies direct tools", () => {
|
||||||
|
expect(isDirectTool("create_project")).toBe(true);
|
||||||
|
expect(isDirectTool("route_trace")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDirectTool rejects routed tools", () => {
|
||||||
|
expect(isDirectTool("add_schematic_wire")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRoutedTool identifies routed tools", () => {
|
||||||
|
expect(isRoutedTool("add_schematic_wire")).toBe(true);
|
||||||
|
expect(isRoutedTool("export_gerber")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRoutedTool rejects direct tools and unknowns", () => {
|
||||||
|
expect(isRoutedTool("create_project")).toBe(false);
|
||||||
|
expect(isRoutedTool("totally_made_up_tool")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getToolCategory maps a tool to its category", () => {
|
||||||
|
expect(getToolCategory("add_schematic_wire")).toBe("schematic");
|
||||||
|
expect(getToolCategory("export_gerber")).toBe("export");
|
||||||
|
expect(getToolCategory("nonexistent_tool")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool registry — invariants", () => {
|
||||||
|
// Schematic essentials are intentionally exposed both as direct tools (so the
|
||||||
|
// AI sees them without first calling list_tool_categories) and via the
|
||||||
|
// "schematic" / "schematic_batch" categories (so they surface during routed
|
||||||
|
// discovery). See the directToolNames comment in src/tools/registry.ts.
|
||||||
|
// Any direct/routed overlap outside this allowlist is unintentional.
|
||||||
|
const INTENTIONAL_OVERLAP = new Set([
|
||||||
|
"add_schematic_component",
|
||||||
|
"list_schematic_components",
|
||||||
|
"annotate_schematic",
|
||||||
|
"connect_passthrough",
|
||||||
|
"connect_to_net",
|
||||||
|
"add_schematic_net_label",
|
||||||
|
"sync_schematic_to_board",
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("direct/routed overlap is limited to the documented schematic essentials", () => {
|
||||||
|
const routed = new Set(getRoutedToolNames());
|
||||||
|
const unexpectedOverlap = directToolNames.filter(
|
||||||
|
(name) => routed.has(name) && !INTENTIONAL_OVERLAP.has(name),
|
||||||
|
);
|
||||||
|
expect(unexpectedOverlap).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no tool name is duplicated across categories", () => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
for (const category of getAllCategories()) {
|
||||||
|
for (const tool of category.tools) {
|
||||||
|
const previous = seen.get(tool);
|
||||||
|
if (previous) {
|
||||||
|
duplicates.push(`${tool} (${previous} + ${category.name})`);
|
||||||
|
} else {
|
||||||
|
seen.set(tool, category.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(duplicates).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool registry — stats", () => {
|
||||||
|
it("getRegistryStats totals match the underlying sources", () => {
|
||||||
|
const stats = getRegistryStats();
|
||||||
|
expect(stats.total_categories).toBe(getAllCategories().length);
|
||||||
|
expect(stats.total_routed_tools).toBe(getRoutedToolNames().length);
|
||||||
|
expect(stats.total_direct_tools).toBe(directToolNames.length);
|
||||||
|
expect(stats.total_tools).toBe(stats.total_routed_tools + stats.total_direct_tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRegistryStats per-category counts match category.tools.length", () => {
|
||||||
|
const stats = getRegistryStats();
|
||||||
|
for (const entry of stats.categories) {
|
||||||
|
const category = getCategory(entry.name);
|
||||||
|
expect(category).toBeDefined();
|
||||||
|
expect(entry.tool_count).toBe(category!.tools.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool registry — search", () => {
|
||||||
|
it("searchTools finds routed tools by substring", () => {
|
||||||
|
const results = searchTools("schematic");
|
||||||
|
expect(results.length).toBeGreaterThan(0);
|
||||||
|
expect(results.some((r) => r.tool === "add_schematic_wire")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searchTools finds direct tools by substring", () => {
|
||||||
|
const results = searchTools("create_project");
|
||||||
|
expect(results.some((r) => r.category === "direct" && r.tool === "create_project")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searchTools returns an empty array for no matches", () => {
|
||||||
|
expect(searchTools("xyzzy_nonexistent_query")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searchTools caps results at 20", () => {
|
||||||
|
const results = searchTools("a");
|
||||||
|
expect(results.length).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests-ts/tool-response.test.ts
Normal file
45
tests-ts/tool-response.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatKicadResult } from "../src/tools/tool-response.js";
|
||||||
|
|
||||||
|
describe("formatKicadResult", () => {
|
||||||
|
it("wraps a successful object result as JSON text content", () => {
|
||||||
|
const response = formatKicadResult({ success: true, value: 42 });
|
||||||
|
expect(response.content).toEqual([
|
||||||
|
{ type: "text", text: JSON.stringify({ success: true, value: 42 }) },
|
||||||
|
]);
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags isError when the KiCAD payload reports success=false", () => {
|
||||||
|
const response = formatKicadResult({ success: false, error: "boom" });
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content[0].text).toBe(JSON.stringify({ success: false, error: "boom" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag isError for payloads without a success field", () => {
|
||||||
|
const response = formatKicadResult({ data: [1, 2, 3] });
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag isError for success=true", () => {
|
||||||
|
const response = formatKicadResult({ success: true });
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag isError when success is a truthy non-false value", () => {
|
||||||
|
const response = formatKicadResult({ success: "ok" });
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles string results", () => {
|
||||||
|
const response = formatKicadResult("hello");
|
||||||
|
expect(response.content[0].text).toBe(JSON.stringify("hello"));
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null without throwing", () => {
|
||||||
|
const response = formatKicadResult(null);
|
||||||
|
expect(response.content[0].type).toBe("text");
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ test module can trigger their import, preventing crashes on systems where the
|
|||||||
real KiCAD environment is not fully initialised for testing.
|
real KiCAD environment is not fully initialised for testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -16,12 +17,13 @@ from unittest.mock import MagicMock
|
|||||||
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
|
# attribute access (pcbnew.BOARD, pcbnew.PCB_TRACK, …) returns a mock
|
||||||
# rather than raising AttributeError.
|
# rather than raising AttributeError.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_pcbnew = MagicMock(name="pcbnew")
|
if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
|
||||||
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
|
_pcbnew = MagicMock(name="pcbnew")
|
||||||
_pcbnew.__name__ = "pcbnew"
|
_pcbnew.__file__ = "/fake/pcbnew.cpython-313-x86_64-linux-gnu.so"
|
||||||
_pcbnew.__spec__ = None
|
_pcbnew.__name__ = "pcbnew"
|
||||||
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
|
_pcbnew.__spec__ = None
|
||||||
sys.modules["pcbnew"] = _pcbnew
|
_pcbnew.GetBuildVersion.return_value = "9.0.0-stub"
|
||||||
|
sys.modules["pcbnew"] = _pcbnew
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Stub: skip (kicad-skip — use real module if available, stub otherwise)
|
# Stub: skip (kicad-skip — use real module if available, stub otherwise)
|
||||||
|
|||||||
176
tests/test_export_cli.py
Normal file
176
tests/test_export_cli.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Unit tests for the kicad-cli-backed export handlers on ``KiCADInterface``.
|
||||||
|
|
||||||
|
These handlers (``_handle_export_*``) shell out to ``kicad-cli``. The tests mock
|
||||||
|
the subprocess call and the filesystem, then assert command construction,
|
||||||
|
validation, and error handling. No real ``kicad-cli`` binary, board, or
|
||||||
|
schematic is touched.
|
||||||
|
|
||||||
|
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules so
|
||||||
|
kicad_interface imports without a real KiCAD install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
from kicad_interface import KiCADInterface # noqa: E402
|
||||||
|
|
||||||
|
# (handler suffix, minimal params) — correct output key per handler, schematicPath for sch tools
|
||||||
|
PCB_HANDLERS = [
|
||||||
|
("gerbers", {"outputDir": "/out"}),
|
||||||
|
("drill", {"outputDir": "/out"}),
|
||||||
|
("ipc2581", {"outputPath": "/out/x.xml"}),
|
||||||
|
("odb", {"outputPath": "/out/x.zip"}),
|
||||||
|
("ipcd356", {"outputPath": "/out/x.d356"}),
|
||||||
|
("gencad", {"outputPath": "/out/x.cad"}),
|
||||||
|
("pos", {"outputPath": "/out/x.pos"}),
|
||||||
|
("pcb_pdf", {"outputPath": "/out/x.pdf"}),
|
||||||
|
("pcb_svg", {"outputPath": "/out/x.svg"}),
|
||||||
|
("pcb_dxf", {"outputPath": "/out/x.dxf"}),
|
||||||
|
("gerber_single", {"outputPath": "/out/x.gbr", "layers": ["F.Cu"]}),
|
||||||
|
("3d_cli", {"outputPath": "/out/x.step", "format": "step"}),
|
||||||
|
]
|
||||||
|
SCH_HANDLERS = [
|
||||||
|
("sch_bom", {"outputPath": "/out/b.csv", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_pdf", {"outputPath": "/out/b.pdf", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_python_bom", {"outputPath": "/out/b.xml", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_svg", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_dxf", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_hpgl", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
("sch_ps", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
|
||||||
|
]
|
||||||
|
ALL_HANDLERS = PCB_HANDLERS + SCH_HANDLERS
|
||||||
|
|
||||||
|
|
||||||
|
def _make_iface():
|
||||||
|
"""KiCADInterface instance with the cli/board-path helpers mocked."""
|
||||||
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
iface.board = None
|
||||||
|
iface._find_kicad_cli_static = MagicMock(return_value="kicad-cli")
|
||||||
|
iface._current_board_path = MagicMock(return_value="/proj/board.kicad_pcb")
|
||||||
|
return iface
|
||||||
|
|
||||||
|
|
||||||
|
def _call(iface, suffix, params, rc=0, stderr=""):
|
||||||
|
"""Invoke a handler with subprocess + filesystem mocked. Returns (result, run_mock)."""
|
||||||
|
method = getattr(iface, f"_handle_export_{suffix}")
|
||||||
|
fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", return_value=fake) as run,
|
||||||
|
patch("pathlib.Path.exists", return_value=True),
|
||||||
|
patch("pathlib.Path.mkdir"),
|
||||||
|
patch("pathlib.Path.iterdir", return_value=[]),
|
||||||
|
patch("pathlib.Path.is_file", return_value=True),
|
||||||
|
):
|
||||||
|
result = method(dict(params))
|
||||||
|
return result, run
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllHandlers:
|
||||||
|
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
|
||||||
|
def test_success_invokes_kicad_cli(self, suffix, params):
|
||||||
|
iface = _make_iface()
|
||||||
|
result, run = _call(iface, suffix, params, rc=0)
|
||||||
|
assert result["success"] is True, result
|
||||||
|
run.assert_called_once()
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert cmd[0] == "kicad-cli"
|
||||||
|
assert cmd[1] in ("pcb", "sch")
|
||||||
|
assert cmd[2] == "export"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
|
||||||
|
def test_cli_not_found(self, suffix, params):
|
||||||
|
iface = _make_iface()
|
||||||
|
iface._find_kicad_cli_static = MagicMock(return_value=None)
|
||||||
|
result, _ = _call(iface, suffix, params)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "kicad-cli" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
|
||||||
|
def test_subprocess_failure_propagates(self, suffix, params):
|
||||||
|
iface = _make_iface()
|
||||||
|
result, _ = _call(iface, suffix, params, rc=1, stderr="boom")
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
def test_missing_output_errors(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
result, _ = _call(iface, "gerbers", {})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_pcb_no_board_resolvable_errors(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
iface._current_board_path = MagicMock(return_value=None)
|
||||||
|
result, _ = _call(iface, "ipc2581", {"outputPath": "/o/x.xml"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_sch_missing_schematic_errors(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
result, _ = _call(iface, "sch_bom", {"outputPath": "/o/b.csv"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlagConstruction:
|
||||||
|
"""Detailed flag mapping for the originally-authored handlers."""
|
||||||
|
|
||||||
|
def test_gerbers_flags(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
_, run = _call(
|
||||||
|
iface,
|
||||||
|
"gerbers",
|
||||||
|
{
|
||||||
|
"outputDir": "/out",
|
||||||
|
"layers": ["F.Cu", "B.Cu"],
|
||||||
|
"subtractSoldermask": True,
|
||||||
|
"noX2": True,
|
||||||
|
"precision": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert "--subtract-soldermask" in cmd
|
||||||
|
assert "--no-x2" in cmd
|
||||||
|
assert "--layers" in cmd
|
||||||
|
assert "F.Cu,B.Cu" in cmd
|
||||||
|
assert cmd[cmd.index("--precision") + 1] == "5"
|
||||||
|
|
||||||
|
def test_gerbers_omitted_flags_absent(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
_, run = _call(iface, "gerbers", {"outputDir": "/out"})
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert "--no-x2" not in cmd
|
||||||
|
assert "--subtract-soldermask" not in cmd
|
||||||
|
|
||||||
|
def test_drill_flags(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
_, run = _call(
|
||||||
|
iface,
|
||||||
|
"drill",
|
||||||
|
{
|
||||||
|
"outputDir": "/out",
|
||||||
|
"format": "gerber",
|
||||||
|
"excellonSeparateTh": True,
|
||||||
|
"generateMap": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert "--excellon-separate-th" in cmd
|
||||||
|
assert "--generate-map" in cmd
|
||||||
|
assert cmd[cmd.index("--format") + 1] == "gerber"
|
||||||
|
|
||||||
|
def test_ipc2581_bom_column_mapping(self):
|
||||||
|
iface = _make_iface()
|
||||||
|
_, run = _call(
|
||||||
|
iface,
|
||||||
|
"ipc2581",
|
||||||
|
{"outputPath": "/o/x.xml", "bomColIntId": "FAST P/N"},
|
||||||
|
)
|
||||||
|
cmd = run.call_args.args[0]
|
||||||
|
assert cmd[cmd.index("--bom-col-int-id") + 1] == "FAST P/N"
|
||||||
@@ -79,6 +79,19 @@ def test_inline_png_returns_base64_image_data(tmp_path):
|
|||||||
assert "filePath" not in result
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_export_frames_to_board_area(tmp_path):
|
||||||
|
"""The export drops the drawing sheet and crops to the board area, so a small
|
||||||
|
board isn't rendered on a mostly-empty A4 page (kicad-cli's default)."""
|
||||||
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
|
with w1, w2 as run, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG):
|
||||||
|
cmd.get_board_2d_view({"pcbPath": str(board_path), "format": "png"})
|
||||||
|
argv = run.call_args[0][0]
|
||||||
|
assert "--exclude-drawing-sheet" in argv
|
||||||
|
assert argv[argv.index("--page-size-mode") + 1] == "2"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_inline_svg_returns_base64_image_data(tmp_path):
|
def test_inline_svg_returns_base64_image_data(tmp_path):
|
||||||
"""responseMode='inline' with format='svg' returns base64-encoded SVG in imageData."""
|
"""responseMode='inline' with format='svg' returns base64-encoded SVG in imageData."""
|
||||||
|
|||||||
46
tests/test_kicad10_paths.py
Normal file
46
tests/test_kicad10_paths.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Regression tests for issue #245: KiCad 10 Windows install paths must be in
|
||||||
|
the auto-discovery candidate lists (footprints and symbols), with env-var
|
||||||
|
overrides for KiCad 10.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
from commands.library import LibraryManager # noqa: E402
|
||||||
|
from commands.library_symbol import SymbolLibraryManager # noqa: E402
|
||||||
|
|
||||||
|
K10_FOOTPRINTS = "C:/Program Files/KiCad/10.0/share/kicad/footprints"
|
||||||
|
K10_SYMBOLS = "C:/Program Files/KiCad/10.0/share/kicad/symbols"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestKicad10FootprintDir:
|
||||||
|
def test_k10_windows_path_is_discovered(self):
|
||||||
|
lm = LibraryManager.__new__(LibraryManager)
|
||||||
|
with patch("commands.library.os.path.isdir", side_effect=lambda p: p == K10_FOOTPRINTS):
|
||||||
|
assert lm._find_kicad_footprint_dir() == K10_FOOTPRINTS
|
||||||
|
|
||||||
|
def test_k10_env_override_wins(self, monkeypatch):
|
||||||
|
lm = LibraryManager.__new__(LibraryManager)
|
||||||
|
monkeypatch.setenv("KICAD10_FOOTPRINT_DIR", "/custom/k10/footprints")
|
||||||
|
with patch("commands.library.os.path.isdir", return_value=True):
|
||||||
|
assert lm._find_kicad_footprint_dir() == "/custom/k10/footprints"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestKicad10SymbolDir:
|
||||||
|
def test_k10_windows_path_is_discovered(self):
|
||||||
|
sm = SymbolLibraryManager.__new__(SymbolLibraryManager)
|
||||||
|
with patch("commands.library_symbol.os.path.isdir", side_effect=lambda p: p == K10_SYMBOLS):
|
||||||
|
assert sm._find_kicad_symbol_dir() == K10_SYMBOLS
|
||||||
|
|
||||||
|
def test_k10_env_override_wins(self, monkeypatch):
|
||||||
|
sm = SymbolLibraryManager.__new__(SymbolLibraryManager)
|
||||||
|
monkeypatch.setenv("KICAD10_SYMBOL_DIR", "/custom/k10/symbols")
|
||||||
|
with patch("commands.library_symbol.os.path.isdir", return_value=True):
|
||||||
|
assert sm._find_kicad_symbol_dir() == "/custom/k10/symbols"
|
||||||
201
tests/test_netclass_kicad_pro_persistence.py
Normal file
201
tests/test_netclass_kicad_pro_persistence.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Tests for create_netclass persisting net class definitions to ``.kicad_pro`` (#185).
|
||||||
|
|
||||||
|
Net class *definitions* live in ``<project>.kicad_pro`` (``net_settings.classes``),
|
||||||
|
not in the ``.kicad_pcb`` board file, on KiCad 7+. These tests exercise the pure
|
||||||
|
JSON transform, the atomic file round-trip, and the create_netclass wiring without
|
||||||
|
needing a live KiCad / SWIG board.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
from commands.routing import ( # noqa: E402
|
||||||
|
RoutingCommands,
|
||||||
|
apply_netclass_to_project_settings,
|
||||||
|
persist_netclass_to_project,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_with_default():
|
||||||
|
return {
|
||||||
|
"net_settings": {
|
||||||
|
"classes": [
|
||||||
|
{
|
||||||
|
"name": "Default",
|
||||||
|
"clearance": 0.2,
|
||||||
|
"track_width": 0.2,
|
||||||
|
"via_diameter": 0.6,
|
||||||
|
"via_drill": 0.3,
|
||||||
|
"priority": 2147483647,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"netclass_patterns": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- pure transform -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_adds_class_with_mm_fields():
|
||||||
|
data = _project_with_default()
|
||||||
|
apply_netclass_to_project_settings(
|
||||||
|
data, "HV", {"clearance": 0.5, "track_width": 0.4, "via_diameter": 1.0, "via_drill": 0.5}
|
||||||
|
)
|
||||||
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
||||||
|
assert hv["clearance"] == 0.5
|
||||||
|
assert hv["track_width"] == 0.4
|
||||||
|
assert hv["via_diameter"] == 1.0
|
||||||
|
assert hv["via_drill"] == 0.5
|
||||||
|
# custom classes get priority 0; the Default class keeps its own
|
||||||
|
assert hv["priority"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_clones_default_field_shape():
|
||||||
|
data = _project_with_default()
|
||||||
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
||||||
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
||||||
|
assert "track_width" in hv and "via_diameter" in hv
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_creates_class_template_when_no_default():
|
||||||
|
data = {"net_settings": {"classes": []}}
|
||||||
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
||||||
|
hv = next(c for c in data["net_settings"]["classes"] if c["name"] == "HV")
|
||||||
|
# full KiCad-10 field set from the fallback template
|
||||||
|
for key in ("bus_width", "track_width", "via_diameter", "via_drill", "line_style"):
|
||||||
|
assert key in hv
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_updates_existing_class_without_duplicating():
|
||||||
|
data = _project_with_default()
|
||||||
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
||||||
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.8})
|
||||||
|
hv = [c for c in data["net_settings"]["classes"] if c["name"] == "HV"]
|
||||||
|
assert len(hv) == 1
|
||||||
|
assert hv[0]["clearance"] == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_creates_net_settings_when_absent():
|
||||||
|
data = {}
|
||||||
|
apply_netclass_to_project_settings(data, "HV", {"clearance": 0.5})
|
||||||
|
assert data["net_settings"]["classes"][0]["name"] == "HV"
|
||||||
|
|
||||||
|
|
||||||
|
# --- file persistence -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_round_trips_through_a_real_file(tmp_path):
|
||||||
|
pro = tmp_path / "proj.kicad_pro"
|
||||||
|
pro.write_text(json.dumps(_project_with_default()))
|
||||||
|
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5, "track_width": 0.4})
|
||||||
|
assert result["persisted"] is True
|
||||||
|
assert result["projectFile"] == str(pro)
|
||||||
|
hv = next(
|
||||||
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
||||||
|
)
|
||||||
|
assert hv["clearance"] == 0.5 and hv["track_width"] == 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_preserves_unrelated_project_and_net_settings_content(tmp_path):
|
||||||
|
pro = tmp_path / "proj.kicad_pro"
|
||||||
|
project = _project_with_default()
|
||||||
|
project["board"] = {"design_settings": {"rules": {"min_clearance": 0.1}}}
|
||||||
|
project["net_settings"]["meta"] = {"version": 4}
|
||||||
|
project["net_settings"]["net_colors"] = {"GND": "rgba(1, 2, 3, 0.5)"}
|
||||||
|
project["net_settings"]["netclass_assignments"] = {"GND": "Default"}
|
||||||
|
pro.write_text(json.dumps(project))
|
||||||
|
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
||||||
|
reloaded = json.loads(pro.read_text())
|
||||||
|
assert reloaded["board"]["design_settings"]["rules"]["min_clearance"] == 0.1
|
||||||
|
assert reloaded["net_settings"]["meta"] == {"version": 4}
|
||||||
|
assert reloaded["net_settings"]["net_colors"] == {"GND": "rgba(1, 2, 3, 0.5)"}
|
||||||
|
assert reloaded["net_settings"]["netclass_assignments"] == {"GND": "Default"}
|
||||||
|
default = next(c for c in reloaded["net_settings"]["classes"] if c["name"] == "Default")
|
||||||
|
assert default["priority"] == 2147483647
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_writes_atomically_leaving_no_temp_file(tmp_path):
|
||||||
|
pro = tmp_path / "proj.kicad_pro"
|
||||||
|
pro.write_text(json.dumps(_project_with_default()))
|
||||||
|
persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
||||||
|
assert [p.name for p in tmp_path.iterdir()] == ["proj.kicad_pro"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_warns_when_no_project_file():
|
||||||
|
result = persist_netclass_to_project(None, "HV", {"clearance": 0.5})
|
||||||
|
assert result["persisted"] is False
|
||||||
|
assert "warning" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_warns_on_malformed_json_and_leaves_file_intact(tmp_path):
|
||||||
|
pro = tmp_path / "proj.kicad_pro"
|
||||||
|
pro.write_text("{not valid json")
|
||||||
|
result = persist_netclass_to_project(str(pro), "HV", {"clearance": 0.5})
|
||||||
|
assert result["persisted"] is False
|
||||||
|
assert str(pro) in result["warning"]
|
||||||
|
assert pro.read_text() == "{not valid json" # never half-written
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_warns_when_path_is_a_directory(tmp_path):
|
||||||
|
# a directory passes os.path.exists() but open()-for-read fails -> hits the except
|
||||||
|
result = persist_netclass_to_project(str(tmp_path), "HV", {"clearance": 0.5})
|
||||||
|
assert result["persisted"] is False
|
||||||
|
assert str(tmp_path) in result["warning"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- create_netclass wiring ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_netclass_persists_canonical_trace_width(tmp_path):
|
||||||
|
# Regression: a schema-conformant call (canonical "traceWidth") must reach
|
||||||
|
# .kicad_pro as track_width, not the cloned Default's value.
|
||||||
|
pro = tmp_path / "p.kicad_pro"
|
||||||
|
pro.write_text(json.dumps(_project_with_default()))
|
||||||
|
board = MagicMock()
|
||||||
|
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
|
||||||
|
result = RoutingCommands(board).create_netclass(
|
||||||
|
{"name": "HV", "traceWidth": 5.0, "clearance": 0.5}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["persisted"] is True
|
||||||
|
hv = next(
|
||||||
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
||||||
|
)
|
||||||
|
assert hv["track_width"] == 5.0
|
||||||
|
assert hv["clearance"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_netclass_persists_even_when_swig_path_fails(tmp_path):
|
||||||
|
# The .kicad_pro write is SWIG-independent: a SWIG throw must not skip it.
|
||||||
|
pro = tmp_path / "p.kicad_pro"
|
||||||
|
pro.write_text(json.dumps(_project_with_default()))
|
||||||
|
board = MagicMock()
|
||||||
|
board.GetNetClasses.side_effect = RuntimeError("swig boom")
|
||||||
|
board.GetFileName.return_value = str(tmp_path / "p.kicad_pcb")
|
||||||
|
result = RoutingCommands(board).create_netclass(
|
||||||
|
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["persisted"] is True
|
||||||
|
assert "swig boom" in result.get("warning", "")
|
||||||
|
hv = next(
|
||||||
|
c for c in json.loads(pro.read_text())["net_settings"]["classes"] if c["name"] == "HV"
|
||||||
|
)
|
||||||
|
assert hv["track_width"] == 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_netclass_fails_when_neither_memory_nor_disk_succeeds(tmp_path):
|
||||||
|
board = MagicMock()
|
||||||
|
board.GetNetClasses.side_effect = RuntimeError("swig boom")
|
||||||
|
# sibling .kicad_pro does not exist -> persistence can't happen either
|
||||||
|
board.GetFileName.return_value = str(tmp_path / "missing.kicad_pcb")
|
||||||
|
result = RoutingCommands(board).create_netclass(
|
||||||
|
{"name": "HV", "traceWidth": 0.4, "clearance": 0.5}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "swig boom" in result["errorDetails"]
|
||||||
43
tests/test_real_pcbnew_matrix.py
Normal file
43
tests/test_real_pcbnew_matrix.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PYTHON_DIR = Path(__file__).parent.parent / "python"
|
||||||
|
sys.path.insert(0, str(PYTHON_DIR))
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.integration,
|
||||||
|
pytest.mark.real_pcbnew,
|
||||||
|
pytest.mark.linux,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def require_real_pcbnew() -> None:
|
||||||
|
if os.environ.get("KICAD_USE_REAL_PCBNEW") != "1":
|
||||||
|
pytest.skip("real pcbnew smoke tests require KICAD_USE_REAL_PCBNEW=1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_commands_create_and_load_board_with_real_pcbnew(tmp_path: Path) -> None:
|
||||||
|
import pcbnew
|
||||||
|
|
||||||
|
from commands.project import ProjectCommands
|
||||||
|
|
||||||
|
version = pcbnew.GetBuildVersion()
|
||||||
|
assert version
|
||||||
|
assert not str(version).endswith("-stub")
|
||||||
|
|
||||||
|
commands = ProjectCommands()
|
||||||
|
result = commands.create_project({"name": "matrix_smoke", "path": str(tmp_path)})
|
||||||
|
|
||||||
|
assert result["success"] is True, result
|
||||||
|
|
||||||
|
board_path = Path(result["project"]["boardPath"])
|
||||||
|
assert board_path.exists()
|
||||||
|
|
||||||
|
board = pcbnew.LoadBoard(str(board_path))
|
||||||
|
assert board is not None
|
||||||
|
assert hasattr(board, "GetFileName")
|
||||||
|
assert board.GetFileName().endswith(".kicad_pcb")
|
||||||
439
tests/test_set_footprint_type.py
Normal file
439
tests/test_set_footprint_type.py
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
"""Unit tests for the ``set_footprint_type`` command.
|
||||||
|
|
||||||
|
Tests exercise both the SWIG-path handler (``ComponentCommands.set_footprint_type``)
|
||||||
|
and the IPC-path handler (``KiCADInterface._ipc_set_footprint_type``).
|
||||||
|
|
||||||
|
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules; these tests
|
||||||
|
configure the relevant attribute constants on that mock so that component.py can
|
||||||
|
run without a real KiCAD install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# The conftest.py-installed pcbnew MagicMock is already in sys.modules.
|
||||||
|
# We set the FP_* integer constants we need once at module level so they are
|
||||||
|
# stable for the life of the test session (conftest doesn't reset them).
|
||||||
|
import pcbnew as _pcbnew_stub # noqa: E402 — must come after sys.path insert
|
||||||
|
|
||||||
|
_pcbnew_stub.FP_THROUGH_HOLE = 1
|
||||||
|
_pcbnew_stub.FP_SMD = 2
|
||||||
|
_pcbnew_stub.FP_EXCLUDE_FROM_POS_FILES = 4
|
||||||
|
_pcbnew_stub.FP_EXCLUDE_FROM_BOM = 8
|
||||||
|
_pcbnew_stub.FP_BOARD_ONLY = 16
|
||||||
|
_pcbnew_stub.FP_DNP = 32
|
||||||
|
_pcbnew_stub.F_CrtYd = 0
|
||||||
|
_pcbnew_stub.B_CrtYd = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_footprint_mock(attrs=0, excluded_pos=False, excluded_bom=False, board_only=False):
|
||||||
|
"""Return a MagicMock mimicking a pcbnew.FOOTPRINT for attribute editing."""
|
||||||
|
fp = MagicMock()
|
||||||
|
fp.GetAttributes.return_value = attrs
|
||||||
|
fp.IsExcludedFromPosFiles.return_value = excluded_pos
|
||||||
|
fp.IsExcludedFromBOM.return_value = excluded_bom
|
||||||
|
fp.IsBoardOnly.return_value = board_only
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_component_commands(fp_mock):
|
||||||
|
"""Return a ComponentCommands wired to a board holding *fp_mock*."""
|
||||||
|
from commands.component import ComponentCommands
|
||||||
|
|
||||||
|
board = MagicMock()
|
||||||
|
board.FindFootprintByReference.return_value = fp_mock
|
||||||
|
cmd = ComponentCommands.__new__(ComponentCommands)
|
||||||
|
cmd.board = board
|
||||||
|
cmd.library_manager = MagicMock()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SWIG path – ComponentCommands.set_footprint_type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFootprintTypeSwig:
|
||||||
|
def test_set_through_hole_clears_smd_bit(self):
|
||||||
|
"""Setting through_hole must set FP_THROUGH_HOLE and clear FP_SMD."""
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_SMD)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "through_hole"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
fp.SetAttributes.assert_called_once()
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert written & _pcbnew_stub.FP_THROUGH_HOLE
|
||||||
|
assert not (written & _pcbnew_stub.FP_SMD)
|
||||||
|
|
||||||
|
def test_set_smd_clears_through_hole_bit(self):
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "U1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert written & _pcbnew_stub.FP_SMD
|
||||||
|
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
|
||||||
|
def test_set_unspecified_clears_both_bits(self):
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE | _pcbnew_stub.FP_SMD)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "TP1", "type": "unspecified"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert not (written & _pcbnew_stub.FP_SMD)
|
||||||
|
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
|
||||||
|
def test_exclude_from_pos_files_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "R2", "type": "smd", "exclude_from_pos_files": True})
|
||||||
|
|
||||||
|
fp.SetExcludedFromPosFiles.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_exclude_from_bom_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "R3", "type": "smd", "exclude_from_bom": True})
|
||||||
|
|
||||||
|
fp.SetExcludedFromBOM.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_not_in_schematic_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type(
|
||||||
|
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
fp.SetBoardOnly.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_omitted_optional_flags_not_called(self):
|
||||||
|
"""When optional flags are omitted, their setters must NOT be called."""
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "C1", "type": "smd"})
|
||||||
|
|
||||||
|
fp.SetExcludedFromPosFiles.assert_not_called()
|
||||||
|
fp.SetExcludedFromBOM.assert_not_called()
|
||||||
|
fp.SetBoardOnly.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_reference_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "reference" in result["errorDetails"].lower()
|
||||||
|
|
||||||
|
def test_invalid_type_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "bad_type"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_component_not_found_returns_error(self):
|
||||||
|
cmd = _make_component_commands(None)
|
||||||
|
cmd.board.FindFootprintByReference.return_value = None
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "ZZZ", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_no_board_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
cmd.board = None
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "no board" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_response_includes_readback_type(self):
|
||||||
|
"""Response component.type must reflect the resolved type after the write."""
|
||||||
|
# First call (before SetAttributes): original value; second call (readback): FP_SMD
|
||||||
|
fp = _make_footprint_mock(attrs=0)
|
||||||
|
fp.GetAttributes.side_effect = [0, _pcbnew_stub.FP_SMD]
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["component"]["type"] == "smd"
|
||||||
|
|
||||||
|
def test_response_includes_exclusion_flags(self):
|
||||||
|
"""Response must include exclude_from_pos_files, exclude_from_bom, not_in_schematic."""
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
fp.GetAttributes.side_effect = [0, 0]
|
||||||
|
fp.IsExcludedFromPosFiles.return_value = True
|
||||||
|
fp.IsExcludedFromBOM.return_value = False
|
||||||
|
fp.IsBoardOnly.return_value = False
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "smd", "exclude_from_pos_files": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
comp = result["component"]
|
||||||
|
assert comp["exclude_from_pos_files"] is True
|
||||||
|
assert comp["exclude_from_bom"] is False
|
||||||
|
assert comp["not_in_schematic"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_component_properties – verify extended attributes in response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetComponentPropertiesAttributes:
|
||||||
|
"""Verify that get_component_properties now returns human-readable type + flags."""
|
||||||
|
|
||||||
|
def _setup(self, attrs, excluded_pos=False, excluded_bom=False, board_only=False):
|
||||||
|
fp = _make_footprint_mock(
|
||||||
|
attrs=attrs,
|
||||||
|
excluded_pos=excluded_pos,
|
||||||
|
excluded_bom=excluded_bom,
|
||||||
|
board_only=board_only,
|
||||||
|
)
|
||||||
|
# get_component_properties needs GetPosition, GetBoundingBox etc.
|
||||||
|
fp.GetPosition.return_value = MagicMock(x=0, y=0)
|
||||||
|
fp.GetOrientation.return_value = MagicMock()
|
||||||
|
fp.GetOrientation.return_value.AsDegrees.return_value = 0.0
|
||||||
|
fp.GetCourtyard.return_value = MagicMock()
|
||||||
|
fp.GetCourtyard.return_value.OutlineCount.return_value = 0
|
||||||
|
fp.GetBoundingBox.return_value = MagicMock(
|
||||||
|
GetLeft=lambda: 0,
|
||||||
|
GetTop=lambda: 0,
|
||||||
|
GetRight=lambda: 0,
|
||||||
|
GetBottom=lambda: 0,
|
||||||
|
)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
# board.GetLayerName must return a string
|
||||||
|
cmd.board.GetLayerName.return_value = "F.Cu"
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def test_smd_type_string(self):
|
||||||
|
cmd = self._setup(_pcbnew_stub.FP_SMD)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["component"]["attributes"]["type"] == "smd"
|
||||||
|
|
||||||
|
def test_through_hole_type_string(self):
|
||||||
|
cmd = self._setup(_pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["component"]["attributes"]["type"] == "through_hole"
|
||||||
|
|
||||||
|
def test_unspecified_type_string(self):
|
||||||
|
cmd = self._setup(0)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["component"]["attributes"]["type"] == "unspecified"
|
||||||
|
|
||||||
|
def test_exclusion_flags_reported(self):
|
||||||
|
cmd = self._setup(
|
||||||
|
_pcbnew_stub.FP_SMD,
|
||||||
|
excluded_pos=True,
|
||||||
|
excluded_bom=True,
|
||||||
|
board_only=False,
|
||||||
|
)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
attrs = result["component"]["attributes"]
|
||||||
|
assert attrs["exclude_from_pos_files"] is True
|
||||||
|
assert attrs["exclude_from_bom"] is True
|
||||||
|
assert attrs["not_in_schematic"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IPC path – KiCADInterface._ipc_set_footprint_type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ipc_iface():
|
||||||
|
"""Construct a minimal KiCADInterface with a mocked IPC board API."""
|
||||||
|
with patch("kicad_interface.USE_IPC_BACKEND", True):
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
iface.use_ipc = True
|
||||||
|
iface.board = None
|
||||||
|
iface.ipc_board_api = MagicMock()
|
||||||
|
iface.component_commands = MagicMock()
|
||||||
|
return iface
|
||||||
|
|
||||||
|
|
||||||
|
def _make_kipy_fp_mock(reference: str):
|
||||||
|
"""Return a MagicMock that looks enough like a kipy Footprint proto wrapper."""
|
||||||
|
fp = MagicMock()
|
||||||
|
fp.reference_field.text.value = reference
|
||||||
|
fp.proto.attributes.mounting_style = 0
|
||||||
|
fp.proto.attributes.exclude_from_position_files = False
|
||||||
|
fp.proto.attributes.exclude_from_bill_of_materials = False
|
||||||
|
fp.proto.attributes.not_in_schematic = False
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def _fms_stub():
|
||||||
|
return types.SimpleNamespace(FMS_THROUGH_HOLE=1, FMS_SMD=2, FMS_UNSPECIFIED=3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFootprintTypeIpc:
|
||||||
|
def test_ipc_sets_mounting_style_smd(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("U1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "U1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.mounting_style == fms.FMS_SMD
|
||||||
|
board_mock.update_items.assert_called_once_with([target_fp])
|
||||||
|
|
||||||
|
def test_ipc_sets_mounting_style_through_hole(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("J1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "J1", "type": "through_hole"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.mounting_style == fms.FMS_THROUGH_HOLE
|
||||||
|
|
||||||
|
def test_ipc_sets_exclude_from_pos_when_provided(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "through_hole", "exclude_from_pos_files": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.exclude_from_position_files is True
|
||||||
|
|
||||||
|
def test_ipc_sets_exclude_from_bom(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "smd", "exclude_from_bom": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.exclude_from_bill_of_materials is True
|
||||||
|
|
||||||
|
def test_ipc_not_in_schematic_written(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("MH1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.not_in_schematic is True
|
||||||
|
|
||||||
|
def test_ipc_component_not_found_returns_error(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = []
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "ZZZ", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_ipc_invalid_type_returns_error(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "bad_type"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_ipc_fallback_to_swig_on_proto_error(self):
|
||||||
|
"""When kipy proto import fails, the handler must fall back to the SWIG path."""
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
iface.board = MagicMock() # SWIG board available
|
||||||
|
|
||||||
|
swig_result = {"success": True, "component": {"reference": "R1", "type": "smd"}}
|
||||||
|
iface.component_commands.set_footprint_type.return_value = swig_result
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": None}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
iface.component_commands.set_footprint_type.assert_called_once()
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result.get("_backend") == "swig"
|
||||||
|
|
||||||
|
def test_ipc_response_includes_backend_marker(self):
|
||||||
|
"""Successful IPC response must carry _backend: 'ipc'."""
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("C1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "C1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result.get("_backend") == "ipc"
|
||||||
|
assert result.get("_realtime") is True
|
||||||
@@ -74,7 +74,7 @@ class TestAddMissingFootprintsFromSchematic:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
patch("kicad_interface.pcbnew") as mock_pcbnew,
|
patch("commands.schematic_handlers.pcbnew") as mock_pcbnew,
|
||||||
patch("commands.library.LibraryManager") as mock_lm_cls,
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
):
|
):
|
||||||
mock_pcbnew.FootprintLoad.return_value = loaded_module
|
mock_pcbnew.FootprintLoad.return_value = loaded_module
|
||||||
@@ -113,7 +113,7 @@ class TestAddMissingFootprintsFromSchematic:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
patch("kicad_interface.pcbnew"),
|
patch("commands.schematic_handlers.pcbnew"),
|
||||||
patch("commands.library.LibraryManager") as mock_lm_cls,
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
):
|
):
|
||||||
lm = MagicMock()
|
lm = MagicMock()
|
||||||
@@ -144,7 +144,7 @@ class TestAddMissingFootprintsFromSchematic:
|
|||||||
{"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""},
|
{"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
patch("kicad_interface.pcbnew"),
|
patch("commands.schematic_handlers.pcbnew"),
|
||||||
patch("commands.library.LibraryManager") as mock_lm_cls,
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
):
|
):
|
||||||
mock_lm_cls.return_value = MagicMock(libraries={})
|
mock_lm_cls.return_value = MagicMock(libraries={})
|
||||||
@@ -172,7 +172,7 @@ class TestAddMissingFootprintsFromSchematic:
|
|||||||
"_extract_components_from_schematic",
|
"_extract_components_from_schematic",
|
||||||
return_value=[{"reference": "R1", "value": "10k", "footprint": ""}],
|
return_value=[{"reference": "R1", "value": "10k", "footprint": ""}],
|
||||||
),
|
),
|
||||||
patch("kicad_interface.pcbnew"),
|
patch("commands.schematic_handlers.pcbnew"),
|
||||||
patch("commands.library.LibraryManager") as mock_lm_cls,
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
):
|
):
|
||||||
mock_lm_cls.return_value = MagicMock(libraries={})
|
mock_lm_cls.return_value = MagicMock(libraries={})
|
||||||
@@ -205,7 +205,7 @@ class TestAddMissingFootprintsFromSchematic:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
patch("kicad_interface.pcbnew"),
|
patch("commands.schematic_handlers.pcbnew"),
|
||||||
patch("commands.library.LibraryManager") as mock_lm_cls,
|
patch("commands.library.LibraryManager") as mock_lm_cls,
|
||||||
):
|
):
|
||||||
mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present
|
mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present
|
||||||
|
|||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["tests-ts/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
reporters: ["verbose"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user