chore: add Vitest scaffolding and sync package.json to v2.2.3
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -35,8 +35,8 @@ jobs:
|
|||||||
- name: Run linter
|
- name: Run linter
|
||||||
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:
|
||||||
|
|||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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