diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 00000000..d7a14ac8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,49 @@ +name: Compatibility report +description: Send the result of `solidworks-mcp-python selftest` on your SolidWorks +labels: ["compatibility"] +body: + - type: markdown + attributes: + value: | + Thanks for checking your setup. With SolidWorks running and idle, run: + + ``` + uvx solidworks-mcp-python selftest + ``` + + It takes about three minutes, opens and closes a few documents of its own, and leaves yours alone. It prints a link that opens this form with the summary filled in; otherwise paste the summary it printed below. Failing results are just as useful as passing ones. + + - type: textarea + id: summary + attributes: + label: Selftest summary + description: The Markdown summary the selftest printed, from "### Selftest" to the last area. + placeholder: "### Selftest: 4 of 4 areas passed" + validations: + required: true + + - type: input + id: solidworks + attributes: + label: SolidWorks version and service pack + placeholder: e.g. 2024 SP5.0 + validations: + required: true + + - type: input + id: windows + attributes: + label: Windows version + placeholder: e.g. Windows 11 24H2 + + - type: input + id: client + attributes: + label: MCP client and model, if you use one + placeholder: e.g. Claude Desktop + Sonnet, VS Code + Copilot + + - type: textarea + id: notes + attributes: + label: Anything else? + description: Add-ins, a non-English install, PDM, anything that might matter. diff --git a/.github/ISSUE_TEMPLATE/feedback.yml b/.github/ISSUE_TEMPLATE/feedback.yml index d6c7f4a2..de49e32d 100644 --- a/.github/ISSUE_TEMPLATE/feedback.yml +++ b/.github/ISSUE_TEMPLATE/feedback.yml @@ -7,6 +7,8 @@ body: value: | Thanks for trying the server. Short answers are fine; skip anything you don't know. For a specific crash or wrong result, a Bug Report gets you a faster fix. + To report how the server runs on your SolidWorks version, run + `uvx solidworks-mcp-python selftest` and use the Compatibility report form. - type: textarea id: tried diff --git a/CHANGELOG.md b/CHANGELOG.md index 357c8dbf..7f3ff5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`solidworks-mcp-python selftest`** (#179; `--selftest` works too). + With SolidWorks running, it builds a part, an assembly, a drawing view + and a STEP export through the MCP tools, checks each against a hand + calculation, and prints a report with a link that opens the new + Compatibility report form already filled in. It never touches a + document you had open. The docs list the releases checked so far. - **Tools the U-joint needed** (#209), each checked on SolidWorks 2026: - `add_mate` takes a face as a point on it (`face_a`, `face_b`) in that part's own coordinates, so a pin mates concentric in its hole; diff --git a/README.md b/README.md index 54c4f4d3..1c2bb15e 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Each GIF is a live test driving SolidWorks 2026 through the MCP tools, with the ## Supported Today - Windows + SolidWorks COM automation for the main CAD lifecycle. -- Tested on SolidWorks 2026. Earlier versions may work but are untested; if you try one, please [open an issue](https://github.com/andrewbartels1/SolidworksMCP-python/issues/new/choose) with what worked. +- Tested on SolidWorks 2026. Earlier versions may work but are untested. To check yours, run `uvx solidworks-mcp-python selftest` with SolidWorks open, then send the result with the [compatibility report form](https://github.com/andrewbartels1/SolidworksMCP-python/issues/new?template=compatibility.yml). The [compatibility page](https://andrewbartels1.github.io/SolidworksMCP-python/compatibility/) lists the releases checked so far. - Modeling, sketching, drawing, analysis, export, automation, templates, and macro tools. - Every tool's support level, and the reason a tool isn't stable yet, is on the [progress page](https://andrewbartels1.github.io/SolidworksMCP-python/progress/). diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 00000000..aeee9635 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,40 @@ +# Compatibility + +The SolidWorks releases the selftest has been run on, and how each area +did. The server is developed against SolidWorks 2026; every other row comes +from a user's report. + +## Check your release + +With SolidWorks open and idle, run: + +```powershell +uvx solidworks-mcp-python selftest +``` + +In about three minutes it checks four areas through the same MCP tools a +client calls, each against a hand calculation: + +| Area | What it does | What it checks | +| --- | --- | --- | +| Part | A 40 × 20 × 10 mm block, then a Ø10 hole through it, then a 2 mm fillet on one edge; a Ø9.5 × 20 mm pin | Volume after each feature (8,000, then 7,214.60, then 7,180.27 mm³; the pin 1,417.64 mm³) and each bounding box | +| Assembly | The block and the pin, mated by two faces: concentric in the hole, bottoms flush | 2 components, 2 mates, no interference | +| Drawing | A new drawing with one isometric view of the block | Exactly one view | +| Export | The block as STEP | The file exists and isn't empty | + +It saves only into a temporary folder it deletes afterwards. It closes only +the documents it opened, without saving them, and never touches one that +was open before it started. + +It prints PASS, FAIL or SKIP per area, then a summary and a link that opens +the [compatibility report form](https://github.com/andrewbartels1/SolidworksMCP-python/issues/new?template=compatibility.yml) +with the summary filled in. A failing report is as useful as a passing one: +it shows what to fix. `--report selftest.md` also writes the summary to a +file. The exit code is 0 when every area passes, 1 when any fails or is +skipped, and 2 when it can't run (SolidWorks not installed or not running). + +## Releases checked + +| SolidWorks | Date | Part | Assembly | Drawing | Export | Report | +| --- | --- | --- | --- | --- | --- | --- | +| 2026 SP4.0 (34.4.0), English | 2026-11-08 | PASS 8/8 | PASS 3/3 | PASS 1/1 | PASS 1/1 | the maintainer's run, and the live test `tests/live/test_live_sw_selftest.py` | diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 5e1b2e25..7459f491 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -63,6 +63,14 @@ Open SolidWorks, restart your client as setup told you, and ask *"What model is VS Code labels every line the server writes to stderr as `[warning]`. That is where its normal log goes, since stdout carries the MCP messages, so those labels aren't problems. +To check the whole setup without a client, run the selftest with SolidWorks open and idle: + +```powershell +uvx solidworks-mcp-python selftest +``` + +It builds a small part, an assembly, a drawing view and a STEP export through the same tools a client calls, checks each against a hand calculation, and prints PASS or FAIL per area. It opens and closes a few documents of its own and leaves yours alone. On a release other than 2026, please send the result with the [compatibility report form](https://github.com/andrewbartels1/SolidworksMCP-python/issues/new?template=compatibility.yml); see [Compatibility](../compatibility.md). + ## Install from source To work on the server itself, install it from a clone. You need [Python 3.13+](https://www.python.org/downloads/windows/), installed with **Add python.exe to PATH** ticked, and Git. diff --git a/mkdocs.yml b/mkdocs.yml index a794613a..0a0b0d2b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,6 +20,7 @@ nav: - Home: index.md - Demos: demos.md - Progress: progress.md + - Compatibility: compatibility.md - Getting Started: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md diff --git a/openspec/changes/ujoint-tool-gaps/.openspec.yaml b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/.openspec.yaml similarity index 100% rename from openspec/changes/ujoint-tool-gaps/.openspec.yaml rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/.openspec.yaml diff --git a/openspec/changes/ujoint-tool-gaps/design.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/design.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/design.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/design.md diff --git a/openspec/changes/ujoint-tool-gaps/proposal.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/proposal.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/proposal.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/proposal.md diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/assembly-mates/spec.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/assembly-mates/spec.md diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/edge-treatment/spec.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/edge-treatment/spec.md diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/error-boundary/spec.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/error-boundary/spec.md diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/extrusion/spec.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/extrusion/spec.md diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/reference-plane/spec.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/specs/tools/reference-plane/spec.md diff --git a/openspec/changes/ujoint-tool-gaps/tasks.md b/openspec/changes/archive/2026-11-07-ujoint-tool-gaps/tasks.md similarity index 100% rename from openspec/changes/ujoint-tool-gaps/tasks.md rename to openspec/changes/archive/2026-11-07-ujoint-tool-gaps/tasks.md diff --git a/openspec/changes/selftest-and-compatibility-report/.openspec.yaml b/openspec/changes/selftest-and-compatibility-report/.openspec.yaml new file mode 100644 index 00000000..e582ee4a --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-07 diff --git a/openspec/changes/selftest-and-compatibility-report/design.md b/openspec/changes/selftest-and-compatibility-report/design.md new file mode 100644 index 00000000..47ee5702 --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/design.md @@ -0,0 +1,191 @@ +## Context + +- The console script is `server.cli_main`. It already routes the `setup` + and `skill` subcommands to their own Typer apps, and runs the server for + anything else. +- `PyWin32Adapter.connect()` binds to a running SolidWorks with + `GetActiveObject`, and **launches one** with `Dispatch` when none is + running. The selftest must not start SolidWorks (spec: exit 2), so it + checks first. +- The checks it needs already exist as adapter reads and tools: + `get_model_geometry_summary` (volume, bounding box, component and mate + counts), `verify_model`, `check_interference`, `list_drawing_views`, + `list_open_documents`, `activate_document`, `close_model(save=False)`, + and `health_check` (`sw_version`). The language and the default + templates have no adapter read yet. +- The live tests' `_ScratchSession` (`tests/live/conftest.py`) already + holds the rule this command needs: close only the documents that are new + since a baseline, and only if they were never saved or were saved to a + file the session wrote. It lives in the tests, so the selftest needs its + own copy in `src/`. +- Face mates by a point on each part (`add_mate` with `face_a`/`face_b`) + arrive with #209 (PR #216). This work branches from main after it merges. + +## Goals / Non-Goals + +**Goals:** + +- One run takes under three minutes on SolidWorks 2026 with the default + settle pauses. +- Every number in a check is worked out in `expected.py` with its formula + written beside it, so a reviewer can redo it by hand. +- A failure says which tool call failed and what it returned. + +**Non-Goals:** + +- No `--mock` mode for users: the mock has no geometry (its volumes and + bounding boxes are `None`), so a mock run would prove nothing. The mock + is used only by the unit tests. +- No automatic upload: the user reads the report and chooses to send it. +- No change to the per-tool support levels (#214). A report is evidence a + maintainer reads before moving a tool up a level. + +## Decisions + +### 1. A subcommand, with `--selftest` as an alias + +`selftest` matches `setup` and `skill`. `cli_main` also maps a leading +`--selftest` to it, because #179 and any early docs use that spelling. The +alternative, a `--selftest` option on the server's Typer command, would +mix the selftest's own options (`--report`) into the server's `--help`. + +### 2. Through the MCP tools, in process + +The selftest builds the same FastMCP server the CLI runs +(`SolidWorksMCPServer` with the loaded config) and calls tools through an +in-memory `fastmcp.Client`, as `tests/live/test_live_sw_ujoint_demo.py` +does. That covers the tool boundary, input validation, the circuit +breaker and the adapter: everything a client's call goes through. Calling +the adapter directly would be quicker to write but would miss the +problems users actually hit at the tool layer. + +### 3. Areas behind one small interface + +``` +selftest/ + __init__.py + cli.py # Typer app: options, precheck, exit codes + runner.py # runs areas in order, skips dependents, always cleans up + areas.py # PartArea, AssemblyArea, DrawingArea, ExportArea + expected.py # hand calculations, one named constant per check + documents.py # DocumentGuard: baseline, temp folder, close-only-ours + report.py # area lines, Markdown summary, prefilled issue link + results.py # Check, AreaResult, ToolCaller (raises ToolFailed) + environment.py# environment section from get_application_info etc. +``` + +Each area has a `name`, the names of the areas it `needs`, and +`async run(context) -> AreaResult`. An `AreaResult` holds `Check`s +(`name`, `expected`, `measured`, `tolerance`, `passed`) and the tool calls +made. The runner does not know what an area does, and the report does not +know how a check was measured, so adding an area touches only `areas.py` +and `expected.py`. + +An area is skipped when a file it needs (the saved block or pin) is +missing, not when an earlier area failed a check, so one wrong value +doesn't hide the other results. + +### 4. The part, the assembly and the hand calculations + +- **Block:** a 40 × 20 mm rectangle on the Top Plane, centred on the + origin, extruded 10 mm. Volume 8,000 mm³; bounding box 40 × 10 × 20 mm. +- **Hole:** a Ø10 circle at the origin, cut through all. Volume + 8,000 − π·5²·10 = 7,214.60 mm³; the bounding box is unchanged. +- **Fillet:** r = 2 mm on one 40 mm top edge, picked by its midpoint + (0, 10, 10) mm. It removes (1 − π/4)·2²·40 = 34.34 mm³, leaving + 7,180.27 mm³. +- **Pin:** a Ø9.5 circle extruded 20 mm. Volume π·4.75²·20 = 1,417.64 mm³. +- **Assembly:** the block and the pin, saved in the temporary folder. A + concentric face mate (the hole's wall at (5, 5, 0), the pin's side at + (4.75, 10, 0)), then a coincident aligned mate of the two bottom faces + ((−15, 0, 0) and (0, 0, 0)). Expected: 2 components, 2 mates, 0 + interferences (0.25 mm radial clearance). +- **Drawing:** a new drawing from the default template, one isometric + view of the block. Expected: 1 view. +- **Export:** the block as STEP into the temporary folder. Expected: the + file exists and its size is above zero. + +Tolerances: volume within 0.01 % (relative), lengths within 0.01 mm. + +### 5. Leaving the user's documents alone + +`DocumentGuard` is an async context manager: + +1. On entry it reads `list_open_documents` as the baseline and makes a + folder with `tempfile.mkdtemp(prefix="solidworks-mcp-selftest-")`. +2. Every path the areas save to is inside that folder. +3. On exit, also after an exception or Ctrl+C, it lists the open documents + again. It closes each one that is not in the baseline and either has no + path or a path inside its folder, using `activate_document` and then + `close_model(save=False)`. Then it deletes the folder. + +A document in the baseline is never activated, so the user's active +document only changes while the selftest's own documents are open in +front of it. + +### 6. Not starting SolidWorks + +Before connecting, `cli.py` checks that `SLDWORKS.exe` is running. It uses +`tasklist /FI "IMAGENAME eq SLDWORKS.exe" /NH`, so it needs no new +dependency and makes no COM call. No process means exit 2 with the +message from the spec. A non-Windows platform, or no SolidWorks install +(`setup.solidworks.installed_versions()` returns empty), is also exit 2. + +### 7. `get_application_info`, the one new adapter read + +`get_application_info() -> AdapterResult[dict]` returns `revision`, +`language`, and `templates` (`part`, `assembly` and `drawing`, each with +`file_name` and `exists`). In the pywin32 adapter it is three +`ISldWorks` reads (`RevisionNumber`, `GetCurrentLanguage`, and +`GetUserPreferenceStringValue` with swDefaultTemplatePart = 8, +swDefaultTemplateAssembly = 9 and swDefaultTemplateDrawing = 10, read from +`swconst.tlb`), run inside `_handle_com_operation`. Each read is wrapped +in `_attempt` so one unreadable value is `None`, not an error. The mock +returns fixed values. Only file names leave the adapter, never full paths +(the spec's privacy rule). + +Python, pywin32 and package versions come from `platform`, +`importlib.metadata` and `solidworks_mcp.version`, not the adapter. + +### 8. The report and the issue link + +- Area lines: `PASS part 6/6 checks`, `SKIP drawing needs part`. +- The Markdown summary is a fenced environment table, then one table per + area (check, expected, measured, tolerance, result), then the tools + called. +- GitHub issue forms take field values from the query string. The link is + `…/issues/new?template=compatibility.yml&labels=compatibility&summary=`. + It is printed only while the whole URL is under 8,000 characters. Past + that, the plain form link is printed with "paste the summary above". A + full report is about 2,500 characters, so the fallback is for failures + with long error messages. + +### 9. The compatibility page + +`docs/compatibility.md` is a table maintained by hand: release and +service pack, date, the four area results, and a report link. A maintainer +adds the row when a report arrives. The README's "Tested on" line links +to it. A generated page was considered, but reports arrive as issues, so +there's no structured source to generate it from yet. + +## Risks / Trade-offs + +- [The user's active document changes while the selftest runs] → The + guard never activates or closes a baseline document. The README and the + command's opening line say the selftest opens and closes a few documents + of its own. +- [Default templates missing or in another language] → `create_part` + already falls back to the installed templates. The environment section + shows what was found, which is often the answer to a failing report. +- [Face-mate behaviour differs on older releases] → That is the point of + the selftest. The assembly area fails with the tool's message, and the + other areas still run. +- [A crash in SolidWorks during the run] → The guard's cleanup is + best-effort. Its failures are printed, never raised over the area + results, and the temporary folder is still removed. +- [`tasklist` output is localised] → It is matched only for the image + name, which is not translated. + +## Migration Plan + +Additive. Nothing to migrate or roll back beyond reverting the PR. diff --git a/openspec/changes/selftest-and-compatibility-report/proposal.md b/openspec/changes/selftest-and-compatibility-report/proposal.md new file mode 100644 index 00000000..893ff254 --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/proposal.md @@ -0,0 +1,64 @@ +## Why + +The README says the server is tested on SolidWorks 2026 only, and asks +users of earlier releases to open an issue. Few do, and the reports that +come in lack the facts needed to act on them. Users need one command that +checks their setup the way an agent would use it, and a form to send the +result. Addresses #179. + +## What Changes + +- New command `solidworks-mcp-python selftest`, with `--selftest` as an alias + for the spelling in #179. With SolidWorks running, it: + - reports the environment: SolidWorks revision and language, the default + part, assembly and drawing templates (and whether each file exists), + the configured `--year`, and the Python, pywin32 and package versions; + - builds a small part through the MCP tools (sketch, extrude, hole, + fillet) and checks its volume and bounding box against hand + calculations after each feature; + - builds an assembly (two components, two face mates) and checks the + component count, mate count and that nothing interferes; + - makes one drawing view and one STEP export, and checks each exists; + - prints a PASS, FAIL or SKIP line per area, then a Markdown summary to + paste into an issue, and a link that opens the issue form with it + filled in when it fits in a URL. +- It never saves over, closes or changes a document that was open before + it started. Its own files go in a new temporary folder that it deletes. +- Exit code 0 when every area passes, 1 when any fails, 2 when it cannot + run (SolidWorks not running, not Windows). +- New issue form `compatibility.yml` asking for the selftest output, the + SolidWorks version and service pack, Windows version and MCP client. + Linked from the README's "Tested on" line and from the feedback form. +- New docs page listing the releases the selftest has passed on, seeded + with SolidWorks 2026 SP4.0. Each report that comes in adds a row. +- One new adapter read, `get_application_info`, for the language and the + default templates (**touches the adapter interface**: base, pywin32 and + mock adapters). + +## Capabilities + +### New Capabilities + +- `setup/selftest`: the selftest command, what it checks, the report it + prints, its exit codes, and its promise to leave the user's documents + alone. +- `docs/compatibility-reports`: the compatibility issue form and the page + of releases the selftest has passed on. + +### Modified Capabilities + +None. + +## Impact + +- New package `src/solidworks_mcp/selftest/`; `server.cli_main` routes the + `selftest` subcommand to it. +- Adapter interface: `get_application_info` in `adapters/base.py`, the + pywin32 adapter and the mock adapter. COM risk is small (three + `ISldWorks` reads on the connect thread through `_handle_com_operation`). +- The assembly step uses the face mates from #209 (PR #216), so this work + is built on top of that branch once it merges. +- `.github/ISSUE_TEMPLATE/compatibility.yml` (new), `feedback.yml`, README, + a new docs page and `mkdocs.yml` nav. +- Tests: mock-adapter tests for the checks and the report, mirroring + `src/`; one live test running the whole selftest on SolidWorks 2026. diff --git a/openspec/changes/selftest-and-compatibility-report/specs/docs/compatibility-reports/spec.md b/openspec/changes/selftest-and-compatibility-report/specs/docs/compatibility-reports/spec.md new file mode 100644 index 00000000..8148e481 --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/specs/docs/compatibility-reports/spec.md @@ -0,0 +1,42 @@ +## Purpose + +Give users a form to send their selftest results, and show everyone which +SolidWorks releases the server has passed the selftest on. + +## ADDED Requirements + +### Requirement: A compatibility issue form + +The repository SHALL offer a "Compatibility report" issue form that asks +for the selftest summary (required), the SolidWorks version and service +pack (required), the Windows version, and the MCP client and model. Its +summary field SHALL accept being filled from the link the selftest prints. +The form SHALL label new issues `compatibility`. + +#### Scenario: The selftest link fills the form + +- **WHEN** the user opens the link the selftest printed +- **THEN** the compatibility form opens with the summary field already holding the selftest's summary + +### Requirement: The form is easy to find + +The README's "Tested on" line and the feedback form SHALL point to the +compatibility form and say how to run the selftest. + +#### Scenario: A user on SolidWorks 2022 reads the README + +- **WHEN** a user reads that the server is tested on SolidWorks 2026 +- **THEN** the same line tells them to run `solidworks-mcp-python selftest` and send the result with the compatibility form + +### Requirement: The releases verified so far are listed + +The docs SHALL have a page listing each SolidWorks release and service +pack the selftest has run on, with the date, each area's result and a link +to the report. It SHALL start with SolidWorks 2026 SP4.0 from the live +test. A release with a failing area SHALL still be listed, with the area +that failed. + +#### Scenario: A report with a failure is added + +- **WHEN** a report from SolidWorks 2024 SP5 shows the drawing area failing +- **THEN** the page lists 2024 SP5 with part, assembly and export PASS, drawing FAIL, and a link to that issue diff --git a/openspec/changes/selftest-and-compatibility-report/specs/setup/selftest/spec.md b/openspec/changes/selftest-and-compatibility-report/specs/setup/selftest/spec.md new file mode 100644 index 00000000..06632691 --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/specs/setup/selftest/spec.md @@ -0,0 +1,100 @@ +## Purpose + +`solidworks-mcp-python selftest` checks a user's SolidWorks setup through +the same MCP tools an agent calls, and prints a report they can send us. + +## ADDED Requirements + +### Requirement: One command checks the setup + +`solidworks-mcp-python selftest` SHALL run the selftest, and +`solidworks-mcp-python --selftest` SHALL do the same. `--year` SHALL be +accepted as it is when starting the server. The selftest SHALL drive +SolidWorks through the registered MCP tools, called with plain dict +arguments, so it exercises the same path an MCP client does. + +#### Scenario: The flag spelling works + +- **WHEN** the user runs `solidworks-mcp-python --selftest --year 2026` +- **THEN** the selftest runs, exactly as `solidworks-mcp-python selftest --year 2026` would + +### Requirement: The environment is reported + +The report SHALL give the SolidWorks revision and language, the default +part, assembly and drawing templates with whether each file exists, the +`--year` in use, the operating system, and the Python, pywin32 and +package versions. A value that cannot be read SHALL be reported as +unknown, never guessed, and SHALL NOT stop the selftest. + +#### Scenario: A missing template is reported, not fatal + +- **WHEN** the default drawing template points at a file that does not exist +- **THEN** the environment section shows that path marked missing, and the other areas still run + +### Requirement: Each area is checked against a hand calculation + +The selftest SHALL check four areas in order: part, assembly, drawing and +export. Each check SHALL compare a value read back from SolidWorks with an +expected value worked out by hand, within a stated tolerance: + +- part: volume and bounding box after the extrude, after the hole and + after the fillet; +- assembly: component count, mate count, and no interference; +- drawing: the drawing has exactly one view of the part; +- export: the STEP file exists and is not empty. + +An area whose setup step fails SHALL be FAIL with the tool's error message. +An area that needs a file an earlier area did not save (the assembly, +drawing and export use the saved parts) SHALL be SKIP, naming what it +needed. An area whose files were saved SHALL still run when an earlier +area failed a check, so one wrong value does not hide the other results. + +#### Scenario: A wrong volume fails its area + +- **WHEN** the part's volume after the hole differs from the hand calculation by more than the tolerance +- **THEN** the part area is FAIL, and its line shows the expected value, the measured value and the tolerance + +#### Scenario: A failed part skips the rest + +- **WHEN** the part area fails before the part is saved +- **THEN** the assembly, drawing and export areas are SKIP, each saying it needs the part + +### Requirement: The report can be pasted into an issue + +The selftest SHALL print one line per area (PASS, FAIL or SKIP, with the +checks passed out of the checks run), then a Markdown summary holding the +environment, each area's checks and the tools called. When the summary +fits, it SHALL also print a link that opens the compatibility issue form +with the summary filled in. With `--report ` it SHALL also write the +summary to that file. The report SHALL NOT include file paths under the +user's profile beyond the templates' file names, nor any MCP client +configuration. + +#### Scenario: A long summary still gets to the form + +- **WHEN** the summary is too long for a link +- **THEN** the selftest prints the plain issue-form link and tells the user to paste the summary printed above it + +### Requirement: Exit codes say what happened + +The selftest SHALL exit 0 when every area passes, 1 when any area fails or +is skipped, and 2 when it cannot run at all: not on Windows, SolidWorks not +installed, or SolidWorks not running. Exit 2 SHALL print what to do next. + +#### Scenario: SolidWorks is not running + +- **WHEN** the user runs the selftest and SolidWorks is not running +- **THEN** it does not start SolidWorks, prints "Start SolidWorks, wait for it to finish loading, then run this again", and exits 2 + +### Requirement: The user's documents are left alone + +The selftest SHALL NOT save, close, activate-and-change or rebuild any +document that was open before it started. It SHALL save only into a new +temporary folder it creates, SHALL close only documents it opened, without +saving, and SHALL delete that folder at the end, also when an area fails +or the run is interrupted. + +#### Scenario: A user's part stays open and unchanged + +- **WHEN** the user has an unsaved part open and runs the selftest +- **THEN** after the selftest that part is still open, still unsaved, and still has the same features, and every document the selftest made is closed diff --git a/openspec/changes/selftest-and-compatibility-report/tasks.md b/openspec/changes/selftest-and-compatibility-report/tasks.md new file mode 100644 index 00000000..2614ac28 --- /dev/null +++ b/openspec/changes/selftest-and-compatibility-report/tasks.md @@ -0,0 +1,32 @@ +## 1. Branch and adapter read + +- [x] 1.1 After PR #216 merges, rebase `feat/selftest` on main (face mates are needed by the assembly area) +- [x] 1.2 Check `ISldWorks.GetCurrentLanguage`, `GetUserPreferenceStringValue` and the swDefaultTemplate* values against the API docs and `swconst.tlb`; note them in this design +- [x] 1.3 Add `get_application_info` to `adapters/base.py` with its docstring and the "not implemented" default +- [x] 1.4 Implement it in the pywin32 adapter (inside `_handle_com_operation`, each read through `_attempt`, file names only) +- [x] 1.5 Implement it in `mock_adapter.py`, and add mock tests for both adapters' results, including an unreadable value becoming `None` +- [x] 1.6 Add it to the circuit-breaker and connection-pool wrappers, with their wrapper tests + +## 2. The selftest package + +- [x] 2.1 `expected.py`: the hand calculations from design §4, each with its formula in a comment +- [x] 2.2 `documents.py`: `DocumentGuard` (baseline, temporary folder, close only its own, delete the folder); tests for a baseline document left open, an unsaved own document closed, cleanup after an exception +- [x] 2.3 `areas.py`: the part, assembly, drawing and export areas, calling tools through an in-memory FastMCP client; tests with a stub client for pass, a wrong value, and a failed tool call +- [x] 2.4 `runner.py`: run in order, skip dependents, always run the guard's cleanup; tests for skip propagation +- [x] 2.5 `environment.py`: the environment section; tests with values missing +- [x] 2.6 `report.py`: area lines, Markdown summary, prefilled link and its fallback past 8,000 characters; tests for both +- [x] 2.7 `cli.py`: `selftest` options (`--year`, `--report`, `--config`), the precheck (Windows, installed, running) and exit codes 0/1/2; tests for each exit code +- [x] 2.8 Route `selftest` and a leading `--selftest` in `server.cli_main`; tests for both spellings + +## 3. Issue form and docs + +- [x] 3.1 `.github/ISSUE_TEMPLATE/compatibility.yml` with the `summary` field id the link fills, labelled `compatibility` (the label is created on GitHub when the PR merges) +- [x] 3.2 Point `feedback.yml` and the README's "Tested on" line at the form and the command +- [x] 3.3 `docs/compatibility.md` (with the nav entry), seeded from the live run in 4.1; mention the command in the install docs +- [x] 3.4 CHANGELOG, and mark #179 in `openspec/queue.md` + +## 4. Live check and sign-off + +- [x] 4.1 Live test `tests/live/test_live_sw_selftest.py`: run the selftest on SolidWorks 2026 with a user-style unsaved part open first; assert every area passes and that part is still open, unsaved and unchanged +- [x] 4.2 Run the command by hand for the user (live-demo sign-off), and paste its report into the compatibility page's first row +- [x] 4.3 Full mock suite at 100% coverage, ruff, and `mkdocs build --strict` diff --git a/openspec/queue.md b/openspec/queue.md index 5ac9c4d1..2fc20177 100644 --- a/openspec/queue.md +++ b/openspec/queue.md @@ -6,17 +6,17 @@ reviewed, then implemented, signed off live where it touches SolidWorks, and archived after its PR merges. Update this file when an item is done or the order changes. -Last updated 2026-11-06. +Last updated 2026-11-07. | # | Item | Issue | OpenSpec change | State | | --- | --- | --- | --- | --- | -| 0 | Hotfix: the monitor-stand GIF looked empty in the README and Demos grids (its first 8 frames were SolidWorks' blank start screen, and GitHub shows only the first frame to readers with reduced motion) | none | none (asset fix) | Fixed 2026-11-06 on `fix/monitor-stand-gif-poster`: the GIF opens on the finished stand for 2 s, then plays the build. Awaiting push approval | +| 0 | Hotfix: the monitor-stand GIF looked empty in the README and Demos grids (its first 8 frames were SolidWorks' blank start screen, and GitHub shows only the first frame to readers with reduced motion) | none | none (asset fix) | Fixed 2026-11-06 on `fix/monitor-stand-gif-poster`: the GIF opens on the finished stand for 2 s, then plays the build. Done (#215) | | 1 | Tool descriptions agents can act on: one description standard, MCP hints and family tags, and a check that keeps them | #82 (docstring half) | `tool-descriptions-for-agents` (archived) | Done 2026-11-05 (#202, #203, #204). Left: after the next release, ask Glama for a rescan and record the score in the internal assessment | | 2 | Guidance through MCP and an installable skill: the server's `instructions`, the skill served as `skill://` resources with FastMCP 3.4.9's `SkillProvider`, and the same skill installable into an agent | #175 | `agent-skill-and-instructions` (archived) | Done 2026-11-06 (#205, #211; #147 closed as superseded) | | 3 | The circuit breaker locks a tool out after a success and never reopens; a reset tool | #208 | `circuit-breaker-recovery` (archived) | Done 2026-11-06 (#213) | | 4 | Support levels for every tool, and COM coverage progress bars at the top of the README with an interactive docs page | #79 (coverage half) | `tool-support-levels-and-progress` (archived) | Done 2026-11-06 (#214). #79's licence-tier half is item 8 | -| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | `ujoint-tool-gaps` | Implemented 2026-11-06 on `feat/ujoint-tool-gaps`; live-checked; awaiting push approval | -| 6 | `--selftest` command and a compatibility-report issue template | #179 | not yet written | | +| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | `ujoint-tool-gaps` (archived) | Done 2026-11-07 (#216), signed off live | +| 6 | `--selftest` command and a compatibility-report issue template | #179 | `selftest-and-compatibility-report` | In progress 2026-11-07 on `feat/selftest` | | 7 | Skills that improve the agent's results, measured on the U-joint prompt (Opus 5.5 at medium effort: the first skill changed how the agent worked, not what it built) | #207 | not yet written | | | 8 | README: licence-tier tags | #79 (other half) | not yet written | | | 9 | Remove the stale `validate_coverage.py`; decide whether mypy gates CI | #157 | not yet written | | diff --git a/openspec/specs/tools/assembly-mates/spec.md b/openspec/specs/tools/assembly-mates/spec.md new file mode 100644 index 00000000..3ca3bd91 --- /dev/null +++ b/openspec/specs/tools/assembly-mates/spec.md @@ -0,0 +1,33 @@ +# tools/assembly-mates Specification + +## Purpose +TBD - created by archiving change ujoint-tool-gaps. Update Purpose after archive. +## Requirements +### Requirement: Mate a face by a point on it + +`add_mate` SHALL accept, for each side, either a named entity or a point on a face in that component's own part coordinates (mm), and SHALL pick the face among that component's faces only. + +#### Scenario: A pin concentric in a hole +- **WHEN** an agent mates a pin's cylindrical face (a point on it) concentric with a bracket's hole face (a point on it) +- **THEN** the mate is added, the pin's axis lies on the hole's axis, and the reply names the faces used + +#### Scenario: No face at the point +- **WHEN** the point is not on any face of the named component +- **THEN** the call fails with `not_found`, names the component and the point, and adds no mate + +#### Scenario: Both kinds for one side +- **WHEN** a side gives both a named entity and a face point +- **THEN** the call fails with `invalid_input` + +### Requirement: Fix, float, rigid and flexible components + +A `set_component_state` tool SHALL fix or float a component of the active assembly, and make a sub-assembly rigid or flexible, changing nothing else about it. + +#### Scenario: Float a component +- **WHEN** a fixed component is set `fixed: false` +- **THEN** it is floating, and the reply reads back `fixed: false` + +#### Scenario: Flexible sub-assembly +- **WHEN** a sub-assembly is set `flexible: true` +- **THEN** its solving is flexible, and its suppression, visibility and referenced configuration are as before + diff --git a/openspec/specs/tools/edge-treatment/spec.md b/openspec/specs/tools/edge-treatment/spec.md index fce8d004..c4c2a4d5 100644 --- a/openspec/specs/tools/edge-treatment/spec.md +++ b/openspec/specs/tools/edge-treatment/spec.md @@ -69,3 +69,15 @@ SHALL be logged as one warning line, not as a fault with a traceback. name that matches no edge - **THEN** the response is an error and the part is unchanged +### Requirement: Chamfers at an angle + +`add_chamfer` SHALL take an `angle` in degrees (default 45) and `flip`, making an angle-distance chamfer with `distance` along one face. + +#### Scenario: A 60 degree chamfer +- **WHEN** an agent chamfers a block's edge 2 mm at 60° +- **THEN** the volume removed is 0.5 x 2 x 2·tan(60°) x the edge length, within 1 %, or with the legs swapped when `flip` is set + +#### Scenario: Default unchanged +- **WHEN** no angle is given +- **THEN** the chamfer is the same 45° chamfer as before + diff --git a/openspec/specs/tools/error-boundary/spec.md b/openspec/specs/tools/error-boundary/spec.md index ab2db618..7742d289 100644 --- a/openspec/specs/tools/error-boundary/spec.md +++ b/openspec/specs/tools/error-boundary/spec.md @@ -101,3 +101,15 @@ rather than retry the same call. - **WHEN** the adapter error reads "SolidWorks built no feature from sketch 'Sketch2' (tried: the requested direction; direction flipped ...)" - **THEN** the payload has `error_type: "feature_not_built"` and a hint telling the caller to change the inputs rather than retry +### Requirement: A plain dict is accepted as a tool's input + +The tool boundary SHALL validate a dict passed where a tool expects its input model, so every tool can be called from a script with a dict. + +#### Scenario: A script passes a dict +- **WHEN** a script calls `check_interference` with `{"components": ["a-1", "b-1"]}` +- **THEN** the tool runs as it would over MCP + +#### Scenario: A bad dict +- **WHEN** the dict fails validation +- **THEN** the tool returns `error_type: invalid_input`, naming the field + diff --git a/openspec/specs/tools/extrusion/spec.md b/openspec/specs/tools/extrusion/spec.md new file mode 100644 index 00000000..c1520898 --- /dev/null +++ b/openspec/specs/tools/extrusion/spec.md @@ -0,0 +1,21 @@ +# tools/extrusion Specification + +## Purpose +TBD - created by archiving change ujoint-tool-gaps. Update Purpose after archive. +## Requirements +### Requirement: The reply says what was extruded + +`create_extrusion` SHALL report the end condition SolidWorks applied and, for a thin wall, its thickness and side, and SHALL NOT echo inputs it didn't apply. + +#### Scenario: Both directions +- **WHEN** an agent extrudes with `both_directions: true` +- **THEN** the body is symmetric about the sketch plane and the reply's `end_condition` is `MidPlane` + +### Requirement: Thin extrudes from an open profile + +`create_extrusion` with `thin_feature` SHALL extrude an open sketch into a wall of `thin_thickness`, on the side `thin_side` names. + +#### Scenario: A bent plate +- **WHEN** an agent extrudes an open L-shaped chain 38.1 mm with a 6.35 mm thin wall +- **THEN** one solid body is made whose volume is the chain's length times 6.35 times 38.1 mm, within 1 % + diff --git a/openspec/specs/tools/reference-plane/spec.md b/openspec/specs/tools/reference-plane/spec.md new file mode 100644 index 00000000..a832d7a9 --- /dev/null +++ b/openspec/specs/tools/reference-plane/spec.md @@ -0,0 +1,25 @@ +# tools/reference-plane Specification + +## Purpose +TBD - created by archiving change ujoint-tool-gaps. Update Purpose after archive. +## Requirements +### Requirement: A plane at an angle about an axis + +`create_reference_plane` SHALL make a plane at `angle` degrees to a reference plane or face, rotated about a named `axis`. + +#### Scenario: 40 degrees about an axis +- **WHEN** an agent asks for a plane at 40° to the Top Plane about an axis lying in it +- **THEN** a new plane is added at 40° to the Top Plane, and its name comes back + +#### Scenario: An angle with no axis +- **WHEN** `angle` is given without `axis` +- **THEN** the call fails with `invalid_input` and says an axis is needed + +### Requirement: A plane from a face + +`create_reference_plane` SHALL accept `face_point`, a point on a planar face in mm, as the reference in place of a named plane. + +#### Scenario: Parallel to a sloped face +- **WHEN** an agent gives a point on a sloped face and an offset +- **THEN** a plane parallel to that face, offset by that distance, is added + diff --git a/src/solidworks_mcp/adapters/base.py b/src/solidworks_mcp/adapters/base.py index 66b3ef32..debbc89f 100644 --- a/src/solidworks_mcp/adapters/base.py +++ b/src/solidworks_mcp/adapters/base.py @@ -1279,6 +1279,24 @@ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]: error="get_model_geometry_summary is not implemented by this adapter", ) + async def get_application_info(self) -> AdapterResult[dict[str, Any]]: + """Describe the connected SolidWorks without changing anything. + + Only file names leave the adapter, never full paths, so the result + can be pasted into a public report. A value that cannot be read is + ``None``, never guessed. + + Returns: + AdapterResult[dict[str, Any]]: ``revision`` (e.g. ``"34.4.0"``), + ``language`` (e.g. ``"english"``) and ``templates``: for each of + ``part``, ``assembly`` and ``drawing``, the default template's + ``file_name`` and whether the file ``exists``. + """ + return AdapterResult( + status=AdapterResultStatus.ERROR, + error="get_application_info is not implemented by this adapter", + ) + async def set_component_state( self, component: str, diff --git a/src/solidworks_mcp/adapters/circuit_breaker.py b/src/solidworks_mcp/adapters/circuit_breaker.py index d4374f6d..e286005e 100644 --- a/src/solidworks_mcp/adapters/circuit_breaker.py +++ b/src/solidworks_mcp/adapters/circuit_breaker.py @@ -1448,6 +1448,18 @@ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]: input_dict={}, ) + async def get_application_info(self) -> AdapterResult[dict[str, Any]]: + """Describe the connected SolidWorks through circuit breaker. + + Returns: + AdapterResult[dict[str, Any]]: The result produced by the operation. + """ + return await self._execute_with_circuit_breaker( + "get_application_info", + lambda: self.adapter.get_application_info(), + input_dict={}, + ) + async def create_drawing_view( self, payload: Any = None ) -> AdapterResult[dict[str, Any]]: diff --git a/src/solidworks_mcp/adapters/connection_pool.py b/src/solidworks_mcp/adapters/connection_pool.py index 5ad99d18..1a97d06e 100644 --- a/src/solidworks_mcp/adapters/connection_pool.py +++ b/src/solidworks_mcp/adapters/connection_pool.py @@ -1355,6 +1355,17 @@ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]: lambda adapter: adapter.get_model_geometry_summary(), ) + async def get_application_info(self) -> AdapterResult[dict[str, Any]]: + """Describe the connected SolidWorks using pool. + + Returns: + AdapterResult[dict[str, Any]]: The result produced by the operation. + """ + return await self._execute_with_pool( + "get_application_info", + lambda adapter: adapter.get_application_info(), + ) + async def create_drawing_view( self, payload: Any = None ) -> AdapterResult[dict[str, Any]]: diff --git a/src/solidworks_mcp/adapters/mock_adapter.py b/src/solidworks_mcp/adapters/mock_adapter.py index cbd3d672..fe5be055 100644 --- a/src/solidworks_mcp/adapters/mock_adapter.py +++ b/src/solidworks_mcp/adapters/mock_adapter.py @@ -326,6 +326,32 @@ async def health_check(self) -> AdapterHealth: }, ) + async def get_application_info(self) -> AdapterResult[dict[str, Any]]: + """Mock SolidWorks description: a fixed 2026 install in English. + + Returns: + AdapterResult[dict[str, Any]]: The result produced by the operation. + """ + if not self._connected: + return AdapterResult( + status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks" + ) + return AdapterResult( + status=AdapterResultStatus.SUCCESS, + data={ + "revision": "34.0.0", + "language": "english", + "templates": { + kind: {"file_name": f"{kind}.{ext}", "exists": True} + for kind, ext in ( + ("part", "prtdot"), + ("assembly", "asmdot"), + ("drawing", "drwdot"), + ) + }, + }, + ) + async def open_model(self, file_path: str) -> AdapterResult[SolidWorksModel]: """Mock opening a SolidWorks model. diff --git a/src/solidworks_mcp/adapters/solidworks/io.py b/src/solidworks_mcp/adapters/solidworks/io.py index af6bff79..a19c8cb0 100644 --- a/src/solidworks_mcp/adapters/solidworks/io.py +++ b/src/solidworks_mcp/adapters/solidworks/io.py @@ -655,6 +655,9 @@ def _as_com(adapter: Any, obj: Any, interface: str) -> Any: # swUserPreferenceIntegerValue_e - values transcribed from swconst.tlb on a # live SW 2026 (3DEXPERIENCE R2026x) install; the published API help does not # print the integers and they are NOT the "obvious" small numbers. +#: swUserPreferenceStringValue_e: the default template for each new document. +_SW_DEFAULT_TEMPLATES = {"part": 8, "assembly": 9, "drawing": 10} + _SW_PREF_UNIT_SYSTEM = 263 _SW_PREF_UNITS_LINEAR = 47 _SW_PREF_UNITS_LINEAR_DECIMALS = 49 @@ -2464,6 +2467,52 @@ def _summarise() -> dict[str, Any]: adapter._handle_com_operation("get_model_geometry_summary", _summarise), ) + async def get_application_info(self) -> AdapterResult[dict[str, Any]]: + """Read SolidWorks' revision, language and default templates. + + Each value is read on its own, so one unreadable value is ``None`` + and the rest still come back. Templates are reported by file name + only, so no path under the user's profile leaves the adapter. + + Returns: + AdapterResult[dict[str, Any]]: See + ``SolidWorksAdapter.get_application_info``. + """ + adapter = self._adapter(self) + app = adapter.swApp + if app is None: + return AdapterResult( + status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks" + ) + + def _text(read: Any) -> str | None: + value = adapter._attempt(read, default=None) + return str(value) if value else None + + def _template(preference: int) -> dict[str, Any]: + path = _text(lambda: app.GetUserPreferenceStringValue(preference)) + return { + "file_name": os.path.basename(path) if path else None, + "exists": bool(path) and os.path.isfile(str(path)), + } + + def _read() -> dict[str, Any]: + return { + "revision": _text( + lambda: adapter._get_attr_or_call(app, "RevisionNumber") + ), + "language": _text(lambda: app.GetCurrentLanguage()), + "templates": { + kind: _template(preference) + for kind, preference in _SW_DEFAULT_TEMPLATES.items() + }, + } + + return cast( + AdapterResult[dict[str, Any]], + adapter._handle_com_operation("get_application_info", _read), + ) + async def pack_and_go_assembly( self, source_path: str, diff --git a/src/solidworks_mcp/selftest/__init__.py b/src/solidworks_mcp/selftest/__init__.py new file mode 100644 index 00000000..ce975035 --- /dev/null +++ b/src/solidworks_mcp/selftest/__init__.py @@ -0,0 +1,8 @@ +"""``solidworks-mcp-python selftest``: check a SolidWorks setup and report it. + +With SolidWorks running, the selftest builds a small part, an assembly, a +drawing view and a STEP export through the same MCP tools an agent calls, +checks each against a hand calculation (``expected.py``), and prints a +report to paste into a compatibility issue. It never touches a document +that was open before it started (``documents.py``). +""" diff --git a/src/solidworks_mcp/selftest/areas.py b/src/solidworks_mcp/selftest/areas.py new file mode 100644 index 00000000..38907a71 --- /dev/null +++ b/src/solidworks_mcp/selftest/areas.py @@ -0,0 +1,269 @@ +"""The selftest's four areas: part, assembly, drawing and export. + +Each area calls tools through ``Context.call`` exactly as an MCP client +would, and appends a ``Check`` per value it reads back. The values it +expects are in ``expected.py``. An area saves what later areas need into +``Context.files``; an area whose ``needs`` aren't there is skipped. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, ClassVar + +from . import expected as ex +from .results import Check, ToolCaller, ToolFailed + + +@dataclass +class Context: + """What the areas share: the tool caller, the folder, and saved files.""" + + call: ToolCaller + folder: Path + files: dict[str, str] = field(default_factory=dict) + + +async def verify( + ctx: Context, label: str, checks: list[Check], **expectations: Any +) -> None: + """Run ``verify_model`` on the active document; append one check per value. + + Args: + ctx: The selftest context. + label: Prefix for the check names, e.g. ``"after the hole"``. + checks: The area's checks, appended to. + **expectations: ``verify_model`` input, e.g. ``volume_mm3=8000``. + """ + reply = await ctx.call( + "verify_model", + { + **expectations, + "relative_tolerance": ex.VOLUME_TOLERANCE, + "length_tolerance_mm": ex.LENGTH_TOLERANCE_MM, + }, + ) + for item in reply.get("checks") or []: + checks.append( + Check( + name=f"{label}: {item.get('name')}", + expected=item.get("expected"), + measured=item.get("actual"), + tolerance=item.get("tolerance"), + unit=str(item.get("unit") or ""), + passed=item.get("passed"), + ) + ) + + +async def _sketch_and_extrude( + ctx: Context, shape: tuple[str, dict[str, Any]], depth: float +) -> None: + """Sketch one shape on the Top Plane and extrude it ``depth`` mm.""" + sketch = await ctx.call("create_sketch", {"plane": "Top"}) + await ctx.call(*shape) + await ctx.call("exit_sketch") + await ctx.call( + "create_extrusion", + {"sketch_name": sketch["sketch"]["name"], "depth": depth}, + ) + + +class PartArea: + """A block with a hole and a fillet, and a pin; both saved for later areas.""" + + name: ClassVar[str] = "part" + needs: ClassVar[tuple[str, ...]] = () + + async def run(self, ctx: Context, checks: list[Check]) -> None: + """Build, check and save the block, then the pin. + + Args: + ctx: The selftest context. + checks: The area's checks, appended to. + """ + await ctx.call("create_part", {"name": "selftest_block"}) + rectangle = {"x1": -20.0, "y1": -10.0, "x2": 20.0, "y2": 10.0} + await _sketch_and_extrude(ctx, ("add_rectangle", rectangle), ex.BLOCK_HEIGHT_MM) + await verify( + ctx, + "block", + checks, + volume_mm3=ex.BLOCK_VOLUME_MM3, + bounding_box_mm=ex.BLOCK_BOX_MM, + ) + + hole = await ctx.call("create_sketch", {"plane": "Top"}) + await ctx.call( + "add_circle", + {"center_x": 0.0, "center_y": 0.0, "radius": ex.HOLE_RADIUS_MM}, + ) + await ctx.call("exit_sketch") + await ctx.call( + "create_cut_extrude", + { + "sketch_name": hole["sketch"]["name"], + "depth": ex.BLOCK_HEIGHT_MM, + "end_condition": "ThroughAllBoth", + }, + ) + await verify( + ctx, + "after the hole", + checks, + volume_mm3=ex.HOLE_VOLUME_MM3, + bounding_box_mm=ex.BLOCK_BOX_MM, + ) + + await ctx.call( + "add_fillet", + {"radius": ex.FILLET_RADIUS_MM, "edge_names": [ex.FILLET_EDGE]}, + ) + await verify( + ctx, + "after the fillet", + checks, + volume_mm3=ex.FILLET_VOLUME_MM3, + bounding_box_mm=ex.BLOCK_BOX_MM, + ) + ctx.files["block"] = await _save(ctx, "selftest_block.SLDPRT") + + await ctx.call("create_part", {"name": "selftest_pin"}) + circle = {"center_x": 0.0, "center_y": 0.0, "radius": ex.PIN_RADIUS_MM} + await _sketch_and_extrude(ctx, ("add_circle", circle), ex.PIN_LENGTH_MM) + await verify( + ctx, + "pin", + checks, + volume_mm3=ex.PIN_VOLUME_MM3, + bounding_box_mm=ex.PIN_BOX_MM, + ) + ctx.files["pin"] = await _save(ctx, "selftest_pin.SLDPRT") + + +async def _save(ctx: Context, file_name: str) -> str: + """Save the active document into the selftest's folder; return its path.""" + path = str(ctx.folder / file_name) + await ctx.call("save_as", {"file_path": path}) + return path + + +class AssemblyArea: + """The pin seated in the block's hole by two face mates.""" + + name: ClassVar[str] = "assembly" + needs: ClassVar[tuple[str, ...]] = ("block", "pin") + + async def run(self, ctx: Context, checks: list[Check]) -> None: + """Insert both parts, mate them, and check counts and clearance. + + Args: + ctx: The selftest context. + checks: The area's checks, appended to. + """ + await ctx.call("create_assembly", {"name": "selftest_assembly"}) + await ctx.call("insert_component", {"file_path": ctx.files["block"]}) + await ctx.call("insert_component", {"file_path": ctx.files["pin"], "x": 100.0}) + names = (await ctx.call("list_components")).get("components") or [] + block, pin = ( + _component(names, "selftest_block"), + _component(names, "selftest_pin"), + ) + for face_a, face_b, mate_type in ( + (ex.HOLE_WALL_POINT, ex.PIN_SIDE_POINT, "concentric"), + (ex.BLOCK_BOTTOM_POINT, ex.PIN_BOTTOM_POINT, "coincident"), + ): + await ctx.call( + "add_mate", + { + "component_a": block, + "component_b": pin, + "face_a": face_a, + "face_b": face_b, + "mate_type": mate_type, + "alignment": "aligned", + }, + ) + await verify( + ctx, + "assembly", + checks, + component_count=ex.COMPONENT_COUNT, + mate_count=ex.MATE_COUNT, + no_interference=True, + ) + + +def _component(names: list[str], part: str) -> str: + """The component made from ``part``. + + Raises: + ToolFailed: ``list_components`` didn't list it. + """ + for name in names: + if part in name: + return name + raise ToolFailed("list_components", f"no component from {part} in {names}") + + +class DrawingArea: + """A new drawing with one isometric view of the block.""" + + name: ClassVar[str] = "drawing" + needs: ClassVar[tuple[str, ...]] = ("block",) + + async def run(self, ctx: Context, checks: list[Check]) -> None: + """Make the drawing and count its views. + + Args: + ctx: The selftest context. + checks: The area's checks, appended to. + """ + await ctx.call("create_drawing", {"name": "selftest_drawing"}) + await ctx.call( + "add_drawing_view", + {"model_file": ctx.files["block"], "view_type": "isometric"}, + ) + views = (await ctx.call("list_drawing_views")).get("count") + checks.append( + Check( + name="drawing: view_count", + expected=ex.DRAWING_VIEW_COUNT, + measured=views, + unit="view", + passed=views == ex.DRAWING_VIEW_COUNT, + ) + ) + + +class ExportArea: + """The block exported as STEP.""" + + name: ClassVar[str] = "export" + needs: ClassVar[tuple[str, ...]] = ("block",) + + async def run(self, ctx: Context, checks: list[Check]) -> None: + """Export the block and check the file. + + Args: + ctx: The selftest context. + checks: The area's checks, appended to. + """ + await ctx.call("activate_document", {"title_or_path": ctx.files["block"]}) + target = ctx.folder / "selftest_block.step" + await ctx.call("export_step", {"file_path": str(target)}) + size = target.stat().st_size if target.is_file() else None + checks.append( + Check( + name="export: step_file_bytes", + expected="> 0", + measured=size, + unit="bytes", + passed=bool(size), + ) + ) + + +#: The areas in the order they run. +AREAS = (PartArea(), AssemblyArea(), DrawingArea(), ExportArea()) diff --git a/src/solidworks_mcp/selftest/cli.py b/src/solidworks_mcp/selftest/cli.py new file mode 100644 index 00000000..8b742f0f --- /dev/null +++ b/src/solidworks_mcp/selftest/cli.py @@ -0,0 +1,188 @@ +"""``solidworks-mcp-python selftest``: options, the precheck and exit codes. + +Exit 0 when every area passes, 1 when any fails or is skipped, 2 when the +selftest can't run at all. +""" + +from __future__ import annotations + +import asyncio +import platform +import subprocess +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import typer + +from ..config import SolidWorksMCPConfig, load_config +from ..setup.solidworks import installed_versions +from .areas import AREAS, Context +from .documents import DocumentGuard +from .environment import read_environment +from .report import render, summary_markdown +from .results import Outcome, ToolCaller +from .runner import run_areas + +EXIT_PASS, EXIT_FAIL, EXIT_CANNOT_RUN = 0, 1, 2 + +NOT_RUNNING = ( + "SolidWorks isn't running. Start SolidWorks, wait for it to finish " + "loading, then run this again." +) + +app = typer.Typer(add_completion=False, no_args_is_help=False) + + +def solidworks_running() -> bool: + """Whether an ``SLDWORKS.exe`` process is running, without touching COM. + + Returns: + bool: True when ``tasklist`` lists one. + """ + listed = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq SLDWORKS.exe", "/NH"], + capture_output=True, + text=True, + check=False, + ) + return "sldworks.exe" in listed.stdout.lower() + + +def precheck( + system: Callable[[], str] = platform.system, + versions: Callable[[], list[int]] = installed_versions, + running: Callable[[], bool] = solidworks_running, +) -> str | None: + """Why the selftest can't run here, or ``None`` when it can. + + Args: + system: Returns the OS name. + versions: Returns the installed SolidWorks years. + running: Whether SolidWorks is running. + + Returns: + str | None: What to do next, or ``None``. + """ + if system() != "Windows": + return "The selftest needs Windows with SolidWorks installed." + if not versions(): + return ( + "No SolidWorks install was found. Install SolidWorks, then run this again." + ) + if not running(): + return NOT_RUNNING + return None + + +async def _start_server(config: SolidWorksMCPConfig) -> Any: + """Build and connect the same server the CLI runs. + + Args: + config: The selftest's configuration. + + Returns: + Any: The set-up ``SolidWorksMCPServer``, its adapter connected. + """ + from ..server import SolidWorksMCPServer + + server = SolidWorksMCPServer(config) + await server.setup() + await server.adapter.connect() + return server + + +async def run_selftest( + config: SolidWorksMCPConfig, + report_path: Path | None, + start_server: Callable[[SolidWorksMCPConfig], Any] = _start_server, +) -> int: + """Run every area against SolidWorks, print the report, return the exit code. + + Args: + config: The configuration, its ``solidworks_year`` set. + report_path: Where to also write the Markdown summary, if anywhere. + start_server: Builds and connects the server (tests pass a stub). + + Returns: + int: ``EXIT_PASS``, ``EXIT_FAIL`` or ``EXIT_CANNOT_RUN``. + """ + from fastmcp import Client + + try: + server = await start_server(config) + except Exception as exc: # noqa: BLE001 - any connect failure means "can't run" + typer.echo(f"Could not connect to SolidWorks: {exc}") + return EXIT_CANNOT_RUN + try: + async with Client(server.mcp) as client: + call = ToolCaller(client) + environment = await read_environment(server.adapter, config.solidworks_year) + guard = DocumentGuard(call) + async with guard: + results = await run_areas(AREAS, Context(call, guard.folder)) + finally: + await server.adapter.disconnect() + typer.echo(render(environment, results, guard.problems)) + if report_path is not None: + report_path.write_text(summary_markdown(environment, results), encoding="utf-8") + typer.echo(f"Summary written to {report_path}") + passed = all(r.outcome is Outcome.PASS for r in results) + return EXIT_PASS if passed else EXIT_FAIL + + +@app.command() +def selftest( + year: int | None = typer.Option( + None, + "--year", + min=1995, + max=2100, + help="The installed SolidWorks release to test, e.g. 2026. " + "Default: the newest installed.", + ), + report: Path | None = typer.Option( + None, "--report", help="Also write the Markdown summary to this file." + ), + config: str | None = typer.Option( + None, "--config", help="Configuration file path." + ), +) -> None: + """Check this SolidWorks setup through the MCP tools and print a report. + + With SolidWorks running, it builds a small part, an assembly, a drawing + view and a STEP export, checks each against a hand calculation, and + prints a summary to send with a compatibility report. It opens and + closes a few documents of its own, and never saves, closes or changes + one you had open. + + \f + Args: + year: The SolidWorks release year. + report: A file to write the Markdown summary to. + config: Path to a configuration file. + + Raises: + typer.Exit: Always, with the exit code. + """ + problem = precheck() + if problem: + typer.echo(problem) + raise typer.Exit(EXIT_CANNOT_RUN) + loaded = load_config(config) + loaded.mock_solidworks = False + loaded.log_level = "WARNING" + loaded.solidworks_year = year or installed_versions()[0] + if not loaded.can_use_solidworks: + typer.echo( + "The configuration asks for the mock adapter; the selftest needs SolidWorks." + ) + raise typer.Exit(EXIT_CANNOT_RUN) + from .. import utils + + utils.setup_logging(loaded) + typer.echo( + f"Testing SolidWorks {loaded.solidworks_year}: this opens and closes a " + "few documents of its own and leaves yours alone. About three minutes." + ) + raise typer.Exit(asyncio.run(run_selftest(loaded, report))) diff --git a/src/solidworks_mcp/selftest/documents.py b/src/solidworks_mcp/selftest/documents.py new file mode 100644 index 00000000..ca3602cb --- /dev/null +++ b/src/solidworks_mcp/selftest/documents.py @@ -0,0 +1,128 @@ +"""Keep the selftest away from the user's documents. + +The guard notes which documents are open before the selftest starts and +gives it a fresh temporary folder to save into. On the way out, also after +an error, it closes, unsaved, only the documents the selftest made, then +deletes the folder. A document that was open before is never activated, +saved or closed. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path +from types import TracebackType +from typing import Any + +from .results import ToolCaller, ToolFailed + +#: Close order: a drawing or assembly before the parts it references. +_CLOSE_ORDER = {"Drawing": 0, "Assembly": 1} + + +class DocumentGuard: + """An async context manager owning the selftest's folder and documents.""" + + def __init__(self, call: ToolCaller) -> None: + """Use ``call`` to list, activate and close documents. + + Args: + call: The selftest's tool caller. + """ + self._call = call + self._baseline: set[str] = set() + self._folder: Path | None = None + self.problems: list[str] = [] + + async def __aenter__(self) -> DocumentGuard: + """Note the open documents and make the folder. + + Returns: + DocumentGuard: This guard, with ``folder`` set. + """ + self._baseline = {d["title"] for d in await self._documents() if d["title"]} + self._folder = Path(tempfile.mkdtemp(prefix="solidworks-mcp-selftest-")) + return self + + @property + def folder(self) -> Path: + """The selftest's temporary folder; only inside ``async with``. + + Raises: + RuntimeError: The guard has not been entered. + """ + if self._folder is None: + raise RuntimeError("DocumentGuard is used outside `async with`") + return self._folder + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Close the selftest's documents and delete its folder. + + Args: + exc_type: The exception's type, if one is propagating. + exc: The exception, if one is propagating. + traceback: Its traceback. + """ + await self.cleanup() + + def is_ours(self, document: dict[str, Any]) -> bool: + """Whether the selftest made ``document``. + + Args: + document: An entry from ``list_open_documents``. + + Returns: + bool: True when it is new since the start and was never saved, + or was saved inside the selftest's folder. + """ + if not document.get("title") or document["title"] in self._baseline: + return False + path = document.get("path") or "" + if not path: + return True + folder = os.path.normcase(str(self.folder.resolve())) + return os.path.normcase(str(Path(path).resolve())).startswith(folder + os.sep) + + async def cleanup(self) -> None: + """Close the selftest's documents, unsaved, then delete its folder. + + A failure is recorded in ``problems``, never raised, so it can't hide + the results it follows. + """ + try: + documents = await self._documents() + except ToolFailed as exc: + self.problems.append(f"could not list the open documents: {exc.message}") + documents = [] + ours = sorted( + (d for d in documents if self.is_ours(d)), + key=lambda d: _CLOSE_ORDER.get(str(d.get("type")), 2), + ) + for document in ours: + try: + await self._call( + "activate_document", + {"title_or_path": document["title"]}, + record=False, + ) + await self._call("close_model", {"save": False}, record=False) + except ToolFailed as exc: + self.problems.append( + f"could not close {document['title']}: {exc.message}" + ) + shutil.rmtree(self.folder, ignore_errors=True) + if self.folder.exists(): + self.problems.append(f"could not delete {self.folder}") + self._folder = None + + async def _documents(self) -> list[dict[str, Any]]: + """The open documents, from ``list_open_documents``.""" + reply = await self._call("list_open_documents", record=False) + return list(reply.get("documents") or []) diff --git a/src/solidworks_mcp/selftest/environment.py b/src/solidworks_mcp/selftest/environment.py new file mode 100644 index 00000000..787fc485 --- /dev/null +++ b/src/solidworks_mcp/selftest/environment.py @@ -0,0 +1,47 @@ +"""The report's environment section: SolidWorks, Python and this package.""" + +from __future__ import annotations + +import platform +from importlib import metadata +from typing import Any + +from ..version import __version__ + + +def _package_version(name: str) -> str | None: + """An installed distribution's version, or ``None`` when it isn't installed.""" + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +async def read_environment(adapter: Any, year: int | None) -> dict[str, Any]: + """Describe the setup the selftest ran on. + + A value that can't be read is ``None``; nothing here stops the run. + + Args: + adapter: The connected adapter. + year: The SolidWorks year the server was started with. + + Returns: + dict[str, Any]: ``solidworks`` (``revision``, ``language``, + ``templates``, ``year``), ``os``, ``python``, ``pywin32`` and + ``solidworks_mcp`` versions. + """ + info = await adapter.get_application_info() + data = info.data if info.is_success and info.data else {} + return { + "solidworks": { + "revision": data.get("revision"), + "language": data.get("language"), + "year": year, + "templates": data.get("templates") or {}, + }, + "os": platform.platform(), + "python": platform.python_version(), + "pywin32": _package_version("pywin32"), + "solidworks_mcp": __version__, + } diff --git a/src/solidworks_mcp/selftest/expected.py b/src/solidworks_mcp/selftest/expected.py new file mode 100644 index 00000000..98437163 --- /dev/null +++ b/src/solidworks_mcp/selftest/expected.py @@ -0,0 +1,53 @@ +"""The selftest's hand calculations: every value it expects, and why. + +Lengths are mm, volumes mm³. The block is sketched on the Top Plane, so in +model axes it spans x -20..20, y 0..10 (the extrude) and z -10..10. +""" + +from __future__ import annotations + +import math + +#: Volume within 0.01 % of the hand calculation. +VOLUME_TOLERANCE = 1e-4 +#: Lengths within 0.01 mm. +LENGTH_TOLERANCE_MM = 0.01 + +# The block: a 40 x 20 mm rectangle centred on the origin, extruded 10 mm. +BLOCK_X_MM, BLOCK_Z_MM, BLOCK_HEIGHT_MM = 40.0, 20.0, 10.0 +BLOCK_BOX_MM = [BLOCK_X_MM, BLOCK_HEIGHT_MM, BLOCK_Z_MM] +BLOCK_VOLUME_MM3 = BLOCK_X_MM * BLOCK_Z_MM * BLOCK_HEIGHT_MM # 8,000 + +# The hole: a Ø10 circle at the origin, cut through all. +HOLE_RADIUS_MM = 5.0 +HOLE_VOLUME_MM3 = BLOCK_VOLUME_MM3 - math.pi * HOLE_RADIUS_MM**2 * BLOCK_HEIGHT_MM +# = 8,000 - π·5²·10 = 7,214.60 + +# The fillet: r 2 on the 40 mm top edge at z = 10, picked by its midpoint +# (in metres, as the tool takes edge points). A fillet of radius r along an +# edge of length L removes the corner square less a quarter circle: +# (1 - π/4)·r²·L. +FILLET_RADIUS_MM = 2.0 +FILLET_EDGE = "0,0.01,0.01" +FILLET_VOLUME_MM3 = ( + HOLE_VOLUME_MM3 - (1 - math.pi / 4) * FILLET_RADIUS_MM**2 * BLOCK_X_MM +) +# = 7,214.602 - 34.336 = 7,180.27 + +# The pin: a Ø9.5 circle extruded 20 mm; 0.25 mm clearance in the Ø10 hole. +PIN_RADIUS_MM = 4.75 +PIN_LENGTH_MM = 20.0 +PIN_BOX_MM = [2 * PIN_RADIUS_MM, PIN_LENGTH_MM, 2 * PIN_RADIUS_MM] +PIN_VOLUME_MM3 = math.pi * PIN_RADIUS_MM**2 * PIN_LENGTH_MM # 1,417.64 + +# The assembly: the pin concentric in the hole, its bottom flush with the +# block's. Face points are in each part's own coordinates, in mm. +HOLE_WALL_POINT = [HOLE_RADIUS_MM, BLOCK_HEIGHT_MM / 2, 0.0] +PIN_SIDE_POINT = [PIN_RADIUS_MM, PIN_LENGTH_MM / 2, 0.0] +BLOCK_BOTTOM_POINT = [-15.0, 0.0, 0.0] # clear of the hole +PIN_BOTTOM_POINT = [0.0, 0.0, 0.0] +COMPONENT_COUNT = 2 +MATE_COUNT = 2 + +# The drawing: one isometric view of the block. +DRAWING_VIEW_COUNT = 1 diff --git a/src/solidworks_mcp/selftest/report.py b/src/solidworks_mcp/selftest/report.py new file mode 100644 index 00000000..1e812833 --- /dev/null +++ b/src/solidworks_mcp/selftest/report.py @@ -0,0 +1,176 @@ +"""The selftest's report: a line per area, a Markdown summary, and an issue link.""" + +from __future__ import annotations + +from collections import Counter +from typing import Any +from urllib.parse import quote + +from .results import AreaResult, Check, Outcome + +#: The compatibility issue form; ``summary`` is the field the link fills. +ISSUE_FORM_URL = ( + "https://github.com/andrewbartels1/SolidworksMCP-python/issues/new" + "?template=compatibility.yml" +) +#: Longest link printed; GitHub refuses much longer ones. +MAX_LINK_LENGTH = 8000 + + +def _value(value: Any) -> str: + """A check value as the report shows it.""" + if value is None: + return "not measured" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, float): + return f"{value:,.2f}" if abs(value) >= 1 else f"{value:g}" + if isinstance(value, list): + return "[" + ", ".join(_value(v) for v in value) + "]" + return str(value) + + +def _unit(unit: str) -> str: + """A unit in plain ASCII, so a Windows console prints it.""" + return unit.replace("³", "3") + + +def _expected(check: Check) -> str: + """A check's expected value with its unit, if it has one.""" + return f"{_value(check.expected)} {_unit(check.unit)}".rstrip() + + +def _tolerance(check: Check) -> str: + """A check's tolerance: relative for a volume, absolute otherwise. + + A count is "exact"; a yes/no or a condition such as "> 0" has none. + """ + if check.tolerance is None: + return "-" if isinstance(check.expected, (bool, str)) else "exact" + if "³" in check.unit: + return f"{check.tolerance * 100:g} %" + return f"{_value(check.tolerance)} {_unit(check.unit)}" + + +def area_line(result: AreaResult) -> str: + """One line for an area: outcome, name, checks passed, and why if not PASS. + + Args: + result: The area's result. + + Returns: + str: e.g. ``PASS part 8/8 checks``. + """ + line = f"{result.outcome.value} {result.name:<9}" + if result.outcome is not Outcome.SKIP: + line += f" {result.passed_count}/{len(result.checks)} checks" + if result.note: + line += f" {result.note}" + return line + + +def _tools(calls: list[str]) -> str: + """The tools an area called, in first-call order, with repeat counts.""" + counts = Counter(calls) + return ", ".join( + f"`{name}`" + (f" x{counts[name]}" if counts[name] > 1 else "") + for name in dict.fromkeys(calls) + ) + + +def _templates(templates: dict[str, Any]) -> str: + """The default templates by file name, marking any that are missing.""" + shown = [] + for kind in ("part", "assembly", "drawing"): + entry = templates.get(kind) or {} + name = entry.get("file_name") or "not set" + missing = "" if entry.get("exists") else " (missing)" + shown.append(f"{kind}: {name}{missing}") + return "; ".join(shown) + + +def summary_markdown(environment: dict[str, Any], results: list[AreaResult]) -> str: + """The summary to paste into a compatibility issue. + + Args: + environment: From ``read_environment``. + results: The areas' results. + + Returns: + str: Markdown: the environment, then each area's checks and tools. + """ + sw = environment.get("solidworks") or {} + passed = sum(1 for r in results if r.outcome is Outcome.PASS) + lines = [ + f"### Selftest: {passed} of {len(results)} areas passed", + "", + "| | |", + "| --- | --- |", + f"| SolidWorks | {_value(sw.get('revision'))} " + f"(year {_value(sw.get('year'))}), {_value(sw.get('language'))} |", + f"| Templates | {_templates(sw.get('templates') or {})} |", + f"| OS | {_value(environment.get('os'))} |", + f"| Python | {_value(environment.get('python'))}, " + f"pywin32 {_value(environment.get('pywin32'))} |", + f"| solidworks-mcp-python | {_value(environment.get('solidworks_mcp'))} |", + ] + for result in results: + lines += ["", f"#### {result.name}: {result.outcome.value}"] + if result.note: + lines += ["", result.note] + if result.checks: + lines += [ + "", + "| Check | Expected | Measured | Tolerance | Result |", + "| --- | --- | --- | --- | --- |", + ] + lines += [ + f"| {c.name} | {_expected(c)} | " + f"{_value(c.measured)} | {_tolerance(c)} | " + f"{'pass' if c.passed else 'FAIL'} |" + for c in result.checks + ] + if result.calls: + lines += ["", f"Tools: {_tools(result.calls)}"] + return "\n".join(lines) + "\n" + + +def issue_link(summary: str) -> str | None: + """The issue-form link with ``summary`` filled in, if it's short enough. + + Args: + summary: The Markdown summary. + + Returns: + str | None: The link, or ``None`` when it would be too long. + """ + link = f"{ISSUE_FORM_URL}&summary={quote(summary)}" + return link if len(link) <= MAX_LINK_LENGTH else None + + +def render( + environment: dict[str, Any], results: list[AreaResult], problems: list[str] +) -> str: + """Everything the command prints after the run. + + Args: + environment: From ``read_environment``. + results: The areas' results. + problems: Cleanup problems, printed but kept out of the summary. + + Returns: + str: The area lines, any cleanup problems, the summary and the link. + """ + summary = summary_markdown(environment, results) + parts = ["\n".join(area_line(r) for r in results)] + if problems: + parts.append("Cleanup:\n" + "\n".join(f" {p}" for p in problems)) + parts.append("Summary to paste into a compatibility report:\n\n" + summary) + link = issue_link(summary) + if link: + parts.append(f"Open the report form with this summary filled in:\n{link}") + else: + parts.append( + "Open the report form and paste the summary above:\n" + ISSUE_FORM_URL + ) + return "\n\n".join(parts) + "\n" diff --git a/src/solidworks_mcp/selftest/results.py b/src/solidworks_mcp/selftest/results.py new file mode 100644 index 00000000..45ed3bab --- /dev/null +++ b/src/solidworks_mcp/selftest/results.py @@ -0,0 +1,127 @@ +"""What a selftest run produces: checks, area results, and the tool caller.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class Outcome(StrEnum): + """An area's result.""" + + PASS = "PASS" + FAIL = "FAIL" + SKIP = "SKIP" + + +@dataclass(frozen=True) +class Check: + """One value read back from SolidWorks against the hand calculation. + + ``passed`` is ``None`` when the value could not be measured, which + counts as a failure. + """ + + name: str + expected: Any + measured: Any + tolerance: Any = None + unit: str = "" + passed: bool | None = None + + +@dataclass +class AreaResult: + """One area's outcome, its checks, the tools it called, and why if not PASS.""" + + name: str + outcome: Outcome + checks: list[Check] = field(default_factory=list) + calls: list[str] = field(default_factory=list) + note: str | None = None + + @property + def passed_count(self) -> int: + """The number of checks that passed.""" + return sum(1 for c in self.checks if c.passed is True) + + +class ToolFailed(Exception): + """A tool call returned ``status: error``.""" + + def __init__(self, tool: str, message: str) -> None: + """Remember which tool failed and its message. + + Args: + tool: The tool's name. + message: The tool's error message. + """ + super().__init__(f"{tool} failed: {message}") + self.tool = tool + self.message = message + + +def tool_reply(result: Any) -> dict[str, Any]: + """A FastMCP client's tool result as the payload dict an agent sees. + + Args: + result: The ``CallToolResult`` from ``Client.call_tool``. + + Returns: + dict[str, Any]: The tool's reply. + """ + data = result.structured_content + if isinstance(data, dict) and set(data) == {"result"}: + data = data["result"] + if isinstance(data, dict): + return data + text = "".join(getattr(c, "text", "") for c in result.content) + try: + parsed = json.loads(text) + except ValueError: + parsed = None + if isinstance(parsed, dict): + return parsed + return {"status": "error" if result.is_error else "success", "message": text} + + +class ToolCaller: + """Call MCP tools by name with dict input, as a client does; log each call.""" + + def __init__(self, client: Any) -> None: + """Wrap a connected ``fastmcp.Client``. + + Args: + client: The client, already entered. + """ + self._client = client + self.calls: list[str] = [] + + async def __call__( + self, name: str, args: dict[str, Any] | None = None, record: bool = True + ) -> dict[str, Any]: + """Call ``name`` and return its reply; raise when it reports an error. + + Args: + name: The tool's name. + args: Its input, sent as ``input_data``; ``None`` for a tool + without input. + record: Whether the call is listed in the report. + + Returns: + dict[str, Any]: The tool's reply. + + Raises: + ToolFailed: The tool replied with ``status: error``. + """ + if record: + self.calls.append(name) + result = await self._client.call_tool( + name, {} if args is None else {"input_data": args}, raise_on_error=False + ) + reply = tool_reply(result) + if reply.get("status") != "success": + raise ToolFailed(name, str(reply.get("message") or "no message")) + return reply diff --git a/src/solidworks_mcp/selftest/runner.py b/src/solidworks_mcp/selftest/runner.py new file mode 100644 index 00000000..4a11d56a --- /dev/null +++ b/src/solidworks_mcp/selftest/runner.py @@ -0,0 +1,70 @@ +"""Run the selftest's areas in order and turn each into an ``AreaResult``.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol + +from .areas import Context +from .results import AreaResult, Check, Outcome, ToolFailed + + +class Area(Protocol): + """One part of the selftest: what it needs, and how it runs.""" + + name: str + needs: tuple[str, ...] + + async def run(self, ctx: Context, checks: list[Check]) -> None: + """Call tools and append a check per value read back. + + Args: + ctx: The selftest context. + checks: The area's checks, appended to. + """ + + +async def run_areas(areas: Iterable[Area], ctx: Context) -> list[AreaResult]: + """Run each area, skipping one whose files an earlier area didn't save. + + A tool error ends its area as FAIL with the tool's message; the checks + made before it are kept. Any other exception propagates, so a bug in + the selftest is never reported as a SolidWorks failure. + + Args: + areas: The areas, in order. + ctx: The selftest context. + + Returns: + list[AreaResult]: One result per area, in order. + """ + results = [] + for area in areas: + missing = [need for need in area.needs if need not in ctx.files] + if missing: + results.append( + AreaResult( + area.name, + Outcome.SKIP, + note=f"needs the saved {' and '.join(missing)}", + ) + ) + continue + first_call = len(ctx.call.calls) + checks: list[Check] = [] + note = None + try: + await area.run(ctx, checks) + except ToolFailed as exc: + note = str(exc) + passed = note is None and bool(checks) and all(c.passed for c in checks) + results.append( + AreaResult( + area.name, + Outcome.PASS if passed else Outcome.FAIL, + checks, + ctx.call.calls[first_call:], + note, + ) + ) + return results diff --git a/src/solidworks_mcp/server.py b/src/solidworks_mcp/server.py index 94faa70e..e7353f9a 100644 --- a/src/solidworks_mcp/server.py +++ b/src/solidworks_mcp/server.py @@ -803,6 +803,11 @@ def cli_main() -> None: skill_app(args=sys.argv[2:], prog_name="solidworks-mcp skill") return + if sys.argv[1:2] in (["selftest"], ["--selftest"]): + from .selftest.cli import app as selftest_app + + selftest_app(args=sys.argv[2:], prog_name="solidworks-mcp selftest") + return typer.run(cli) diff --git a/tests/live/test_live_sw_selftest.py b/tests/live/test_live_sw_selftest.py new file mode 100644 index 00000000..31a521de --- /dev/null +++ b/tests/live/test_live_sw_selftest.py @@ -0,0 +1,111 @@ +"""The selftest on real SolidWorks: every area passes, the user's part is untouched. + +Before the run, the test opens a "user" part: a block that was never saved +and has unsaved changes. After it, that part must still be open, unsaved, +with the same features, and every document the selftest made must be gone. + +Run with:: + + powershell -ExecutionPolicy Bypass -File tests\\scripts\\live_demo.ps1 ` + -Test tests/live/test_live_sw_selftest.py + +Collected only with ``SOLIDWORKS_MCP_RUN_REAL_INTEGRATION=1`` on Windows (see +``tests/live/conftest.py``). +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from tests.live.evidence import evidence + + +class _KeepConnected: + """The live adapter, minus ``disconnect``: the fixture owns the connection.""" + + def __init__(self, adapter: Any) -> None: + self._adapter = adapter + + def __getattr__(self, name: str) -> Any: + return getattr(self._adapter, name) + + async def disconnect(self) -> None: + return None + + +async def _users_part(adapter: Any) -> Any: + """A never-saved block with unsaved changes, left as the active document.""" + from solidworks_mcp.adapters.base import ExtrusionParameters + + assert (await adapter.create_part()).is_success + assert (await adapter.create_sketch("Front")).is_success + assert (await adapter.add_rectangle(0.0, 0.0, 30.0, 30.0)).is_success + assert (await adapter.exit_sketch()).is_success + assert (await adapter.create_extrusion(ExtrusionParameters(depth=5.0))).is_success + return adapter.currentModel + + +def _state(adapter: Any, doc: Any) -> dict[str, Any]: + """What must not change about the user's part.""" + read = adapter._get_attr_or_call + return { + "title": str(read(doc, "GetTitle")), + "path": str(read(doc, "GetPathName") or ""), + "unsaved_changes": bool(read(doc, "GetSaveFlag")), + "features": int(read(doc, "GetFeatureCount")), + } + + +@pytest.mark.asyncio +@pytest.mark.timeout(900) +async def test_selftest_passes_and_leaves_the_users_part_alone( + scratch, tmp_path: Path, capsys +) -> None: + """Run the selftest's whole flow on SolidWorks 2026 with a user part open.""" + from fastmcp import FastMCP + + from solidworks_mcp.config import SolidWorksMCPConfig + from solidworks_mcp.selftest.cli import EXIT_PASS, run_selftest + from solidworks_mcp.tools import register_tools + + adapter = scratch.adapter + users = await _users_part(adapter) + before = _state(adapter, users) + evidence("the user's part, open and unsaved, before the selftest", before) + + config = SolidWorksMCPConfig() + config.solidworks_year = 2026 + + async def _server(cfg: SolidWorksMCPConfig) -> SimpleNamespace: + mcp = FastMCP("selftest-live") + await register_tools(mcp, adapter, cfg) + return SimpleNamespace(mcp=mcp, adapter=_KeepConnected(adapter)) + + report = tmp_path / "selftest.md" + code = await run_selftest(config, report, _server) + printed = capsys.readouterr().out + lines = [ + line for line in printed.splitlines() if line[:4] in {"PASS", "FAIL", "SKIP"} + ] + evidence("selftest result per area", {"exit_code": code, "areas": lines}) + + after = _state(adapter, users) + open_titles = [ + d.get("title") for d in (await adapter.list_open_documents()).data or [] + ] + evidence( + "the user's part after the selftest, and every open document", + {"after": after, "open": open_titles}, + ) + + summary = report.read_text(encoding="utf-8") + print(summary) + assert code == EXIT_PASS, printed + assert len(lines) == 4 and all(line.startswith("PASS") for line in lines) + assert after == before + assert open_titles == [before["title"]] + assert "Cleanup:" not in printed diff --git a/tests/solidworks_mcp/adapters/solidworks/test_io_application_info.py b/tests/solidworks_mcp/adapters/solidworks/test_io_application_info.py new file mode 100644 index 00000000..85a96796 --- /dev/null +++ b/tests/solidworks_mcp/adapters/solidworks/test_io_application_info.py @@ -0,0 +1,102 @@ +"""Tests for the pywin32 adapter's get_application_info (COM fakes).""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from solidworks_mcp.adapters.pywin32_adapter import PyWin32Adapter + + +def _build_adapter(monkeypatch) -> PyWin32Adapter: + monkeypatch.setattr( + "solidworks_mcp.adapters.pywin32_adapter.PYWIN32_AVAILABLE", True + ) + monkeypatch.setattr( + "solidworks_mcp.adapters.pywin32_adapter.platform.system", + lambda: "Windows", + ) + monkeypatch.setattr( + "solidworks_mcp.adapters.pywin32_adapter.pywintypes", + SimpleNamespace(com_error=RuntimeError), + raising=False, + ) + monkeypatch.setattr( + "solidworks_mcp.adapters.pywin32_adapter._ComSessionCoordinator", + lambda _adapter: SimpleNamespace(), + raising=False, + ) + return PyWin32Adapter({}) + + +class _App: + """ISldWorks answering the three reads, each of which can be made to fail.""" + + def __init__(self, templates: dict[int, str], language: object = "english"): + self.templates = templates + self.language = language + self.RevisionNumber = "34.4.0" + + def GetCurrentLanguage(self): # noqa: N802 + if isinstance(self.language, Exception): + raise self.language + return self.language + + def GetUserPreferenceStringValue(self, preference): # noqa: N802 + return self.templates.get(preference, "") + + +@pytest.mark.asyncio +async def test_reads_revision_language_and_template_names( + monkeypatch, tmp_path: Path +) -> None: + adapter = _build_adapter(monkeypatch) + part = tmp_path / "Part.prtdot" + part.write_text("") + adapter.swApp = _App( + { + 8: str(part), + 9: str(tmp_path / "missing.asmdot"), + } + ) + + result = await adapter.get_application_info() + + assert result.is_success, result.error + assert result.data == { + "revision": "34.4.0", + "language": "english", + "templates": { + "part": {"file_name": "Part.prtdot", "exists": True}, + "assembly": {"file_name": "missing.asmdot", "exists": False}, + "drawing": {"file_name": None, "exists": False}, + }, + } + assert str(tmp_path) not in str(result.data) + + +@pytest.mark.asyncio +async def test_an_unreadable_value_is_none_and_the_rest_still_come_back( + monkeypatch, +) -> None: + adapter = _build_adapter(monkeypatch) + adapter.swApp = _App({}, language=RuntimeError("Member not found")) + + result = await adapter.get_application_info() + + assert result.is_success, result.error + assert result.data["language"] is None + assert result.data["revision"] == "34.4.0" + + +@pytest.mark.asyncio +async def test_not_connected_is_an_error(monkeypatch) -> None: + adapter = _build_adapter(monkeypatch) + adapter.swApp = None + + result = await adapter.get_application_info() + + assert not result.is_success + assert result.error == "Not connected to SolidWorks" diff --git a/tests/solidworks_mcp/adapters/test_base_application_info.py b/tests/solidworks_mcp/adapters/test_base_application_info.py new file mode 100644 index 00000000..f4692cfa --- /dev/null +++ b/tests/solidworks_mcp/adapters/test_base_application_info.py @@ -0,0 +1,17 @@ +"""The base adapter's application-info default reports a missing capability.""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.adapters.base import SolidWorksAdapter +from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter + + +@pytest.mark.asyncio +async def test_base_default_says_the_adapter_lacks_it() -> None: + # Call the base implementation directly: every concrete adapter overrides it. + result = await SolidWorksAdapter.get_application_info(MockSolidWorksAdapter({})) + + assert not result.is_success + assert result.error == "get_application_info is not implemented by this adapter" diff --git a/tests/solidworks_mcp/adapters/test_circuit_breaker_application_info.py b/tests/solidworks_mcp/adapters/test_circuit_breaker_application_info.py new file mode 100644 index 00000000..dac5b2e8 --- /dev/null +++ b/tests/solidworks_mcp/adapters/test_circuit_breaker_application_info.py @@ -0,0 +1,34 @@ +"""get_application_info passes through CircuitBreakerAdapter.""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus +from solidworks_mcp.adapters.circuit_breaker import CircuitBreakerAdapter + + +class _InfoStub: + def __init__(self) -> None: + self.calls = 0 + + async def get_application_info(self) -> AdapterResult[dict]: + self.calls += 1 + return AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"revision": "34.4.0"} + ) + + +def test_breaker_defines_its_own_passthrough() -> None: + assert "get_application_info" in vars(CircuitBreakerAdapter) + + +@pytest.mark.asyncio +async def test_info_is_forwarded() -> None: + stub = _InfoStub() + + result = await CircuitBreakerAdapter(adapter=stub).get_application_info() + + assert result.is_success + assert result.data == {"revision": "34.4.0"} + assert stub.calls == 1 diff --git a/tests/solidworks_mcp/adapters/test_connection_pool_application_info.py b/tests/solidworks_mcp/adapters/test_connection_pool_application_info.py new file mode 100644 index 00000000..259c2488 --- /dev/null +++ b/tests/solidworks_mcp/adapters/test_connection_pool_application_info.py @@ -0,0 +1,47 @@ +"""get_application_info passes through ConnectionPoolAdapter.""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus +from solidworks_mcp.adapters.connection_pool import ConnectionPoolAdapter + + +class _InfoStub: + def __init__(self) -> None: + self.calls = 0 + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + def is_connected(self) -> bool: + return True + + async def get_application_info(self) -> AdapterResult[dict]: + self.calls += 1 + return AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"language": "english"} + ) + + +def test_pool_defines_its_own_passthrough() -> None: + assert "get_application_info" in vars(ConnectionPoolAdapter) + + +@pytest.mark.asyncio +async def test_info_is_forwarded() -> None: + stub = _InfoStub() + pool = ConnectionPoolAdapter(adapter_factory=lambda: stub, max_size=1) + await pool.connect() + try: + result = await pool.get_application_info() + finally: + await pool.disconnect() + + assert result.is_success + assert result.data == {"language": "english"} + assert stub.calls == 1 diff --git a/tests/solidworks_mcp/adapters/test_mock_adapter_application_info.py b/tests/solidworks_mcp/adapters/test_mock_adapter_application_info.py new file mode 100644 index 00000000..7fa2c7b3 --- /dev/null +++ b/tests/solidworks_mcp/adapters/test_mock_adapter_application_info.py @@ -0,0 +1,31 @@ +"""The mock adapter describes a fixed SolidWorks 2026 install.""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter + + +@pytest.mark.asyncio +async def test_connected_mock_reports_fixed_values() -> None: + adapter = MockSolidWorksAdapter({}) + await adapter.connect() + + result = await adapter.get_application_info() + + assert result.is_success + assert result.data["revision"] == "34.0.0" + assert result.data["language"] == "english" + assert result.data["templates"]["drawing"] == { + "file_name": "drawing.drwdot", + "exists": True, + } + + +@pytest.mark.asyncio +async def test_disconnected_mock_says_so() -> None: + result = await MockSolidWorksAdapter({}).get_application_info() + + assert not result.is_success + assert result.error == "Not connected to SolidWorks" diff --git a/tests/solidworks_mcp/selftest/__init__.py b/tests/solidworks_mcp/selftest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/solidworks_mcp/selftest/caller_stub.py b/tests/solidworks_mcp/selftest/caller_stub.py new file mode 100644 index 00000000..5d6fbac4 --- /dev/null +++ b/tests/solidworks_mcp/selftest/caller_stub.py @@ -0,0 +1,36 @@ +"""A scripted stand-in for the selftest's ToolCaller.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from solidworks_mcp.selftest.results import ToolFailed + +Reply = dict[str, Any] | Callable[[dict[str, Any] | None], dict[str, Any]] + + +class CallerStub: + """Answer each tool from ``replies``; fail the tools named in ``failures``.""" + + def __init__( + self, + replies: dict[str, Reply] | None = None, + failures: dict[str, str] | None = None, + ) -> None: + self.replies = replies or {} + self.failures = failures or {} + self.calls: list[str] = [] + self.log: list[tuple[str, dict[str, Any] | None, bool]] = [] + + async def __call__( + self, name: str, args: dict[str, Any] | None = None, record: bool = True + ) -> dict[str, Any]: + if record: + self.calls.append(name) + self.log.append((name, args, record)) + if name in self.failures: + raise ToolFailed(name, self.failures[name]) + reply = self.replies.get(name, {}) + reply = reply(args) if callable(reply) else reply + return {"status": "success", **reply} diff --git a/tests/solidworks_mcp/selftest/test_areas.py b/tests/solidworks_mcp/selftest/test_areas.py new file mode 100644 index 00000000..8548e981 --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_areas.py @@ -0,0 +1,151 @@ +"""The four areas call the right tools with the hand-calculated values.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from solidworks_mcp.selftest import expected as ex +from solidworks_mcp.selftest.areas import ( + AREAS, + AssemblyArea, + Context, + DrawingArea, + ExportArea, + PartArea, +) +from solidworks_mcp.selftest.results import Check, ToolFailed + +from .caller_stub import CallerStub + + +def _verify(args: dict) -> dict: + """verify_model passing every expectation it was given.""" + tolerances = {"relative_tolerance", "length_tolerance_mm"} + return { + "checks": [ + { + "name": key, + "expected": value, + "actual": value, + "tolerance": 0.0, + "unit": "mm³" if key == "volume_mm3" else "", + "passed": True, + } + for key, value in args.items() + if key not in tolerances + ] + } + + +def _stub(**extra) -> CallerStub: + sketches = iter(["Sketch1", "Sketch2", "Sketch3"]) + return CallerStub( + { + "create_sketch": lambda _a: {"sketch": {"name": next(sketches)}}, + "verify_model": _verify, + "list_components": {"components": ["selftest_block-1", "selftest_pin-1"]}, + "list_drawing_views": {"count": 1}, + **extra, + } + ) + + +def test_areas_run_in_the_designed_order() -> None: + assert [a.name for a in AREAS] == ["part", "assembly", "drawing", "export"] + + +@pytest.mark.asyncio +async def test_part_builds_checks_and_saves_both_parts(tmp_path: Path) -> None: + stub = _stub() + ctx = Context(stub, tmp_path) + checks: list[Check] = [] + + await PartArea().run(ctx, checks) + + assert ctx.files == { + "block": str(tmp_path / "selftest_block.SLDPRT"), + "pin": str(tmp_path / "selftest_pin.SLDPRT"), + } + volumes = {c.name: c.expected for c in checks if c.name.endswith("volume_mm3")} + assert volumes == { + "block: volume_mm3": ex.BLOCK_VOLUME_MM3, + "after the hole: volume_mm3": ex.HOLE_VOLUME_MM3, + "after the fillet: volume_mm3": ex.FILLET_VOLUME_MM3, + "pin: volume_mm3": ex.PIN_VOLUME_MM3, + } + assert len(checks) == 8 and all(c.passed for c in checks) + cut = next(a for n, a, _ in stub.log if n == "create_cut_extrude") + assert cut == { + "sketch_name": "Sketch2", + "depth": 10.0, + "end_condition": "ThroughAllBoth", + } + verify = next(a for n, a, _ in stub.log if n == "verify_model") + assert verify["relative_tolerance"] == ex.VOLUME_TOLERANCE + + +@pytest.mark.asyncio +async def test_assembly_mates_by_faces_and_checks_counts(tmp_path: Path) -> None: + stub = _stub() + ctx = Context(stub, tmp_path, {"block": "b.SLDPRT", "pin": "p.SLDPRT"}) + checks: list[Check] = [] + + await AssemblyArea().run(ctx, checks) + + mates = [a for n, a, _ in stub.log if n == "add_mate"] + assert [(m["mate_type"], m["face_a"], m["face_b"]) for m in mates] == [ + ("concentric", ex.HOLE_WALL_POINT, ex.PIN_SIDE_POINT), + ("coincident", ex.BLOCK_BOTTOM_POINT, ex.PIN_BOTTOM_POINT), + ] + assert mates[0]["component_a"] == "selftest_block-1" + assert [c.name for c in checks] == [ + "assembly: component_count", + "assembly: mate_count", + "assembly: no_interference", + ] + + +@pytest.mark.asyncio +async def test_assembly_without_the_pin_component_is_a_tool_failure( + tmp_path: Path, +) -> None: + stub = _stub(list_components={"components": ["selftest_block-1"]}) + ctx = Context(stub, tmp_path, {"block": "b.SLDPRT", "pin": "p.SLDPRT"}) + + with pytest.raises(ToolFailed, match="no component from selftest_pin"): + await AssemblyArea().run(ctx, []) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("count", "passed"), [(1, True), (3, False)]) +async def test_drawing_counts_its_views(tmp_path: Path, count, passed) -> None: + stub = _stub(list_drawing_views={"count": count}) + checks: list[Check] = [] + + await DrawingArea().run(Context(stub, tmp_path, {"block": "b.SLDPRT"}), checks) + + assert checks == [ + Check("drawing: view_count", 1, count, unit="view", passed=passed) + ] + view = next(a for n, a, _ in stub.log if n == "add_drawing_view") + assert view == {"model_file": "b.SLDPRT", "view_type": "isometric"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("content", "size"), [(b"ISO-10303-21;", 13), (None, None)]) +async def test_export_checks_the_file(tmp_path: Path, content, size) -> None: + def export(args: dict) -> dict: + if content is not None: + Path(args["file_path"]).write_bytes(content) + return {} + + stub = _stub(export_step=export) + checks: list[Check] = [] + + await ExportArea().run(Context(stub, tmp_path, {"block": "b.SLDPRT"}), checks) + + assert checks[0].measured == size + assert checks[0].passed is (size is not None) + assert stub.log[0][1] == {"title_or_path": "b.SLDPRT"} diff --git a/tests/solidworks_mcp/selftest/test_cli.py b/tests/solidworks_mcp/selftest/test_cli.py new file mode 100644 index 00000000..cff606b0 --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_cli.py @@ -0,0 +1,158 @@ +"""The selftest command: precheck, exit codes, and the run against a server.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastmcp import FastMCP +from typer.testing import CliRunner + +from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter +from solidworks_mcp.config import SolidWorksMCPConfig +from solidworks_mcp.selftest import cli +from solidworks_mcp.selftest.results import AreaResult, Outcome +from solidworks_mcp.tools import register_tools + + +@pytest.mark.parametrize( + ("system", "versions", "running", "message"), + [ + ("Linux", [2026], True, "needs Windows"), + ("Windows", [], True, "No SolidWorks install"), + ("Windows", [2026], False, "SolidWorks isn't running"), + ("Windows", [2026], True, None), + ], +) +def test_precheck(system, versions, running, message) -> None: + problem = cli.precheck(lambda: system, lambda: versions, lambda: running) + if message is None: + assert problem is None + else: + assert message in problem + + +@pytest.mark.parametrize( + ("stdout", "running"), + [("SLDWORKS.exe 1234 Console", True), ("INFO: No tasks", False)], +) +def test_solidworks_running_reads_tasklist(monkeypatch, stdout, running) -> None: + seen = {} + + def _run(args, **kwargs): + seen["args"] = args + return SimpleNamespace(stdout=stdout) + + monkeypatch.setattr(cli.subprocess, "run", _run) + + assert cli.solidworks_running() is running + assert seen["args"][:2] == ["tasklist", "/FI"] + + +async def _mock_server(config: SolidWorksMCPConfig) -> SimpleNamespace: + adapter = MockSolidWorksAdapter({}) + await adapter.connect() + mcp = FastMCP("selftest-test") + await register_tools(mcp, adapter, config) + return SimpleNamespace(mcp=mcp, adapter=adapter) + + +@pytest.mark.asyncio +async def test_a_run_against_the_mock_fails_and_writes_the_report( + tmp_path: Path, capsys +) -> None: + config = SolidWorksMCPConfig() + config.solidworks_year = 2026 + report = tmp_path / "selftest.md" + + code = await cli.run_selftest(config, report, _mock_server) + + out = capsys.readouterr().out + assert code == cli.EXIT_FAIL + assert "FAIL part" in out + assert "SKIP assembly" in out + assert report.read_text(encoding="utf-8").startswith("### Selftest: 0 of 4") + assert f"Summary written to {report}" in out + + +@pytest.mark.asyncio +async def test_every_area_passing_exits_zero(monkeypatch) -> None: + async def _all_pass(areas, ctx): + return [AreaResult(a.name, Outcome.PASS) for a in areas] + + monkeypatch.setattr(cli, "run_areas", _all_pass) + + code = await cli.run_selftest(SolidWorksMCPConfig(), None, _mock_server) + + assert code == cli.EXIT_PASS + + +@pytest.mark.asyncio +async def test_a_connect_failure_cannot_run(capsys) -> None: + async def _refuse(_config): + raise RuntimeError("SldWorks.Application not found") + + code = await cli.run_selftest(SolidWorksMCPConfig(), None, _refuse) + + assert code == cli.EXIT_CANNOT_RUN + assert ( + "Could not connect to SolidWorks: SldWorks.Application not found" + in capsys.readouterr().out + ) + + +@pytest.mark.asyncio +async def test_start_server_builds_and_connects_the_real_server() -> None: + config = SolidWorksMCPConfig(mock_solidworks=True) + + server = await cli._start_server(config) + try: + assert server.adapter.is_connected() + assert "verify_model" in {t.name for t in await server.mcp.list_tools()} + finally: + await server.adapter.disconnect() + + +def test_command_exits_two_when_precheck_fails(monkeypatch) -> None: + monkeypatch.setattr(cli, "precheck", lambda: cli.NOT_RUNNING) + + result = CliRunner().invoke(cli.app, []) + + assert result.exit_code == cli.EXIT_CANNOT_RUN + assert cli.NOT_RUNNING in result.output + + +def test_command_refuses_a_mock_only_setup(monkeypatch) -> None: + monkeypatch.setattr(cli, "precheck", lambda: None) + monkeypatch.setattr(cli, "installed_versions", lambda: [2026]) + monkeypatch.setattr( + SolidWorksMCPConfig, "can_use_solidworks", property(lambda self: False) + ) + + result = CliRunner().invoke(cli.app, []) + + assert result.exit_code == cli.EXIT_CANNOT_RUN + assert "the selftest needs SolidWorks" in result.output + + +def test_command_runs_with_the_newest_year_by_default(monkeypatch) -> None: + seen = {} + + async def _run(config, report_path): + seen["year"], seen["report"] = config.solidworks_year, report_path + return cli.EXIT_PASS + + monkeypatch.setattr(cli, "precheck", lambda: None) + monkeypatch.setattr(cli, "installed_versions", lambda: [2026, 2024]) + monkeypatch.setattr( + SolidWorksMCPConfig, "can_use_solidworks", property(lambda self: True) + ) + monkeypatch.setattr(cli, "run_selftest", _run) + monkeypatch.setattr("solidworks_mcp.utils.setup_logging", lambda config: None) + + result = CliRunner().invoke(cli.app, ["--report", "out.md"]) + + assert result.exit_code == cli.EXIT_PASS, result.output + assert seen == {"year": 2026, "report": Path("out.md")} + assert "Testing SolidWorks 2026" in result.output diff --git a/tests/solidworks_mcp/selftest/test_documents.py b/tests/solidworks_mcp/selftest/test_documents.py new file mode 100644 index 00000000..9fc3544d --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_documents.py @@ -0,0 +1,100 @@ +"""DocumentGuard closes only the selftest's documents and removes its folder.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from solidworks_mcp.selftest.documents import DocumentGuard + +from .caller_stub import CallerStub + + +class _Documents: + """list_open_documents answering the baseline first, then later lists.""" + + def __init__(self, *lists: list[dict]) -> None: + self.lists = list(lists) + + def __call__(self, _args) -> dict: + return { + "documents": self.lists.pop(0) if len(self.lists) > 1 else self.lists[0] + } + + +def _doc(title: str, path: str = "", kind: str = "Part") -> dict: + return {"title": title, "path": path, "type": kind} + + +@pytest.mark.asyncio +async def test_only_new_documents_unsaved_or_in_the_folder_are_closed() -> None: + users = [_doc("bracket.SLDPRT", r"C:\work\bracket.SLDPRT"), _doc("Part1")] + documents = _Documents(users, []) + stub = CallerStub({"list_open_documents": documents}) + guard = DocumentGuard(stub) + + async with guard: + folder = guard.folder + assert folder.is_dir() + documents.lists = [ + users + + [ + _doc("Part7"), + _doc("selftest_block.SLDPRT", str(folder / "selftest_block.SLDPRT")), + _doc("other.SLDPRT", r"C:\work\other.SLDPRT"), + _doc("Assem3", kind="Assembly"), + _doc("", ""), + ] + ] + + closed = [a["title_or_path"] for n, a, _ in stub.log if n == "activate_document"] + assert closed == ["Assem3", "Part7", "selftest_block.SLDPRT"] + assert all(not record for _n, _a, record in stub.log) + assert stub.calls == [] + assert not folder.exists() + assert guard.problems == [] + + +@pytest.mark.asyncio +async def test_cleanup_runs_after_an_exception_and_records_problems( + monkeypatch, tmp_path: Path +) -> None: + stub = CallerStub( + {"list_open_documents": {"documents": []}}, + ) + guard = DocumentGuard(stub) + monkeypatch.setattr( + "solidworks_mcp.selftest.documents.shutil.rmtree", lambda *a, **k: None + ) + + with pytest.raises(RuntimeError, match="area bug"): + async with guard: + folder = guard.folder + stub.replies["list_open_documents"] = {"documents": [_doc("Part2")]} + stub.failures["close_model"] = "busy" + raise RuntimeError("area bug") + + assert guard.problems == [ + "could not close Part2: busy", + f"could not delete {folder}", + ] + folder.rmdir() + + +@pytest.mark.asyncio +async def test_a_failed_listing_is_a_problem_not_an_error() -> None: + stub = CallerStub({"list_open_documents": {"documents": []}}) + guard = DocumentGuard(stub) + + async with guard: + folder = guard.folder + stub.failures["list_open_documents"] = "SolidWorks is busy" + + assert guard.problems == ["could not list the open documents: SolidWorks is busy"] + assert not folder.exists() + + +def test_the_folder_is_only_there_inside_async_with() -> None: + with pytest.raises(RuntimeError, match="outside `async with`"): + _ = DocumentGuard(CallerStub()).folder diff --git a/tests/solidworks_mcp/selftest/test_environment.py b/tests/solidworks_mcp/selftest/test_environment.py new file mode 100644 index 00000000..5ca7a924 --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_environment.py @@ -0,0 +1,50 @@ +"""read_environment reports what it can read and None for the rest.""" + +from __future__ import annotations + +from importlib import metadata + +import pytest + +from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus +from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter +from solidworks_mcp.selftest import environment +from solidworks_mcp.version import __version__ + + +@pytest.mark.asyncio +async def test_reads_solidworks_and_versions() -> None: + adapter = MockSolidWorksAdapter({}) + await adapter.connect() + + env = await environment.read_environment(adapter, 2026) + + assert env["solidworks"]["revision"] == "34.0.0" + assert env["solidworks"]["year"] == 2026 + assert env["solidworks"]["templates"]["part"]["file_name"] == "part.prtdot" + assert env["solidworks_mcp"] == __version__ + assert env["python"] + + +@pytest.mark.asyncio +async def test_an_unreadable_solidworks_is_none_and_pywin32_may_be_missing( + monkeypatch, +) -> None: + class _Adapter: + async def get_application_info(self): + return AdapterResult(status=AdapterResultStatus.ERROR, error="busy") + + def _missing(name): + raise metadata.PackageNotFoundError(name) + + monkeypatch.setattr(environment.metadata, "version", _missing) + + env = await environment.read_environment(_Adapter(), None) + + assert env["solidworks"] == { + "revision": None, + "language": None, + "year": None, + "templates": {}, + } + assert env["pywin32"] is None diff --git a/tests/solidworks_mcp/selftest/test_expected.py b/tests/solidworks_mcp/selftest/test_expected.py new file mode 100644 index 00000000..c6fbf8aa --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_expected.py @@ -0,0 +1,23 @@ +"""The selftest's hand calculations come out as the design says.""" + +from __future__ import annotations + +import pytest + +from solidworks_mcp.selftest import expected as ex + + +def test_block_hole_fillet_and_pin_volumes() -> None: + assert ex.BLOCK_VOLUME_MM3 == 8000.0 + assert ex.HOLE_VOLUME_MM3 == pytest.approx(7214.60, abs=0.01) + assert ex.FILLET_VOLUME_MM3 == pytest.approx(7180.27, abs=0.01) + assert ex.PIN_VOLUME_MM3 == pytest.approx(1417.64, abs=0.01) + + +def test_boxes_are_in_model_axes() -> None: + assert ex.BLOCK_BOX_MM == [40.0, 10.0, 20.0] + assert ex.PIN_BOX_MM == [9.5, 20.0, 9.5] + + +def test_the_pin_clears_the_hole() -> None: + assert ex.HOLE_RADIUS_MM - ex.PIN_RADIUS_MM == pytest.approx(0.25) diff --git a/tests/solidworks_mcp/selftest/test_report.py b/tests/solidworks_mcp/selftest/test_report.py new file mode 100644 index 00000000..dd96acf0 --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_report.py @@ -0,0 +1,93 @@ +"""The area lines, the Markdown summary and the issue link.""" + +from __future__ import annotations + +from urllib.parse import unquote + +from solidworks_mcp.selftest import report +from solidworks_mcp.selftest.results import AreaResult, Check, Outcome + +_ENV = { + "solidworks": { + "revision": "34.4.0", + "language": "english", + "year": 2026, + "templates": { + "part": {"file_name": "Part.prtdot", "exists": True}, + "drawing": {"file_name": "Drawing.drwdot", "exists": False}, + }, + }, + "os": "Windows-11", + "python": "3.13.13", + "pywin32": "311", + "solidworks_mcp": "1.5.0", +} + +_PART = AreaResult( + "part", + Outcome.PASS, + [ + Check("block: volume_mm3", 8000.0, 8000.004, 1e-4, "mm³", True), + Check( + "block: bounding_box_mm", + [40.0, 10.0, 20.0], + [40.0, 10.0, 20.0], + 0.01, + "mm", + True, + ), + Check("pin: solid", 0.5, None, None, "", None), + Check("flag", True, False, None, "", False), + ], + ["create_part", "create_sketch", "create_part"], +) +_SKIPPED = AreaResult("assembly", Outcome.SKIP, note="needs the saved pin") + + +def test_area_lines() -> None: + assert report.area_line(_PART) == "PASS part 2/4 checks" + assert report.area_line(_SKIPPED) == "SKIP assembly needs the saved pin" + + +def test_summary_shows_environment_checks_and_tools() -> None: + summary = report.summary_markdown(_ENV, [_PART, _SKIPPED]) + + assert summary.startswith("### Selftest: 1 of 2 areas passed") + assert "| SolidWorks | 34.4.0 (year 2026), english |" in summary + assert ( + "| Templates | part: Part.prtdot; assembly: not set (missing); " + "drawing: Drawing.drwdot (missing) |" + ) in summary + assert "| block: volume_mm3 | 8,000.00 mm3 | 8,000.00 | 0.01 % | pass |" in summary + assert ( + "| [40.00, 10.00, 20.00] mm | [40.00, 10.00, 20.00] | 0.01 mm | pass |" + in summary + ) + assert "| pin: solid | 0.5 | not measured | exact | FAIL |" in summary + assert "| flag | yes | no | - | FAIL |" in summary + assert "Tools: `create_part` x2, `create_sketch`" in summary + assert "#### assembly: SKIP\n\nneeds the saved pin" in summary + + +def test_the_link_fills_the_form_until_it_is_too_long() -> None: + link = report.issue_link("### Selftest") + assert link == report.ISSUE_FORM_URL + "&summary=%23%23%23%20Selftest" + assert report.issue_link("x" * report.MAX_LINK_LENGTH) is None + + +def test_render_prints_lines_problems_summary_and_link() -> None: + text = report.render(_ENV, [_PART], ["could not close Part2: busy"]) + + assert text.startswith("PASS part") + assert "Cleanup:\n could not close Part2: busy" in text + link = text.strip().splitlines()[-1] + assert unquote(link.split("&summary=")[1]) == report.summary_markdown(_ENV, [_PART]) + + +def test_render_falls_back_to_the_plain_form(monkeypatch) -> None: + monkeypatch.setattr(report, "MAX_LINK_LENGTH", 10) + + text = report.render(_ENV, [_SKIPPED], []) + + assert "Cleanup" not in text + assert text.rstrip().endswith("paste the summary above:\n" + report.ISSUE_FORM_URL) diff --git a/tests/solidworks_mcp/selftest/test_results.py b/tests/solidworks_mcp/selftest/test_results.py new file mode 100644 index 00000000..9efcc87a --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_results.py @@ -0,0 +1,94 @@ +"""Check, AreaResult, tool_reply and ToolCaller.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from solidworks_mcp.selftest.results import ( + AreaResult, + Check, + Outcome, + ToolCaller, + ToolFailed, + tool_reply, +) + + +def _result(structured=None, text="", is_error=False): + return SimpleNamespace( + structured_content=structured, + content=[SimpleNamespace(text=text)], + is_error=is_error, + ) + + +def test_passed_count_counts_only_true() -> None: + result = AreaResult( + "part", + Outcome.FAIL, + [ + Check("a", 1, 1, passed=True), + Check("b", 1, None), + Check("c", 1, 2, passed=False), + ], + ) + assert result.passed_count == 1 + + +def test_tool_failed_carries_tool_and_message() -> None: + exc = ToolFailed("add_mate", "no face there") + assert (exc.tool, exc.message, str(exc)) == ( + "add_mate", + "no face there", + "add_mate failed: no face there", + ) + + +@pytest.mark.parametrize( + ("result", "reply"), + [ + (_result({"result": {"status": "success"}}), {"status": "success"}), + (_result({"status": "error"}), {"status": "error"}), + (_result(text='{"status": "success", "n": 1}'), {"status": "success", "n": 1}), + (_result(text="boom", is_error=True), {"status": "error", "message": "boom"}), + (_result(text="[1, 2]"), {"status": "success", "message": "[1, 2]"}), + ], +) +def test_tool_reply_reads_every_shape(result, reply) -> None: + assert tool_reply(result) == reply + + +class _Client: + def __init__(self, reply: dict) -> None: + self.reply = reply + self.sent: list[tuple] = [] + + async def call_tool(self, name, arguments, raise_on_error): + self.sent.append((name, arguments, raise_on_error)) + return _result(self.reply) + + +@pytest.mark.asyncio +async def test_caller_sends_input_data_and_records_the_call() -> None: + client = _Client({"status": "success", "ok": True}) + call = ToolCaller(client) + + reply = await call("create_part", {"name": "p"}) + await call("list_open_documents", record=False) + + assert reply == {"status": "success", "ok": True} + assert client.sent == [ + ("create_part", {"input_data": {"name": "p"}}, False), + ("list_open_documents", {}, False), + ] + assert call.calls == ["create_part"] + + +@pytest.mark.asyncio +async def test_caller_raises_on_an_error_reply() -> None: + call = ToolCaller(_Client({"status": "error"})) + + with pytest.raises(ToolFailed, match="add_fillet failed: no message"): + await call("add_fillet", {"radius": 2}) diff --git a/tests/solidworks_mcp/selftest/test_runner.py b/tests/solidworks_mcp/selftest/test_runner.py new file mode 100644 index 00000000..59882498 --- /dev/null +++ b/tests/solidworks_mcp/selftest/test_runner.py @@ -0,0 +1,67 @@ +"""run_areas: pass, fail, skip, and a bug that is not a SolidWorks failure.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from solidworks_mcp.selftest.areas import Context +from solidworks_mcp.selftest.results import Check, Outcome, ToolFailed +from solidworks_mcp.selftest.runner import run_areas + +from .caller_stub import CallerStub + + +class _Area: + def __init__(self, name, needs=(), checks=(), saves=(), error=None) -> None: + self.name, self.needs = name, tuple(needs) + self._checks, self._saves, self._error = list(checks), saves, error + + async def run(self, ctx: Context, checks: list[Check]) -> None: + await ctx.call(f"{self.name}_tool") + checks.extend(self._checks) + for key in self._saves: + ctx.files[key] = f"{key}.SLDPRT" + if self._error: + raise self._error + + +@pytest.mark.asyncio +async def test_outcomes_and_skips(tmp_path: Path) -> None: + ok = Check("v", 1, 1, passed=True) + ctx = Context(CallerStub(), tmp_path) + areas = [ + _Area("part", checks=[ok, Check("w", 1, 2, passed=False)], saves=["block"]), + _Area("assembly", needs=["block", "pin"]), + _Area("drawing", needs=["block"], checks=[ok]), + _Area( + "export", + needs=["block"], + checks=[ok], + error=ToolFailed("export_step", "no"), + ), + _Area("empty"), + ] + + results = await run_areas(areas, ctx) + + assert [(r.name, r.outcome) for r in results] == [ + ("part", Outcome.FAIL), + ("assembly", Outcome.SKIP), + ("drawing", Outcome.PASS), + ("export", Outcome.FAIL), + ("empty", Outcome.FAIL), + ] + assert results[1].note == "needs the saved pin" + assert results[2].calls == ["drawing_tool"] + assert results[3].note == "export_step failed: no" + assert results[3].checks == [ok] + + +@pytest.mark.asyncio +async def test_a_bug_in_an_area_propagates(tmp_path: Path) -> None: + with pytest.raises(ZeroDivisionError): + await run_areas( + [_Area("part", error=ZeroDivisionError())], Context(CallerStub(), tmp_path) + ) diff --git a/tests/solidworks_mcp/test_server.py b/tests/solidworks_mcp/test_server.py index 790a1f4b..4c9bd992 100644 --- a/tests/solidworks_mcp/test_server.py +++ b/tests/solidworks_mcp/test_server.py @@ -1062,6 +1062,27 @@ def test_cli_main_routes_skill_to_the_skill_app(): mock_run.assert_not_called() +@pytest.mark.parametrize("spelling", ["selftest", "--selftest"]) +def test_cli_main_routes_selftest_to_the_selftest_app(spelling): + """`solidworks-mcp selftest` and `--selftest` both run the selftest.""" + from solidworks_mcp.server import cli_main + + with ( + patch( + "solidworks_mcp.server.sys.argv", + ["solidworks-mcp", spelling, "--year", "2026"], + ), + patch("solidworks_mcp.selftest.cli.app") as selftest_app, + patch("solidworks_mcp.server.typer.run") as mock_run, + ): + cli_main() + + selftest_app.assert_called_once_with( + args=["--year", "2026"], prog_name="solidworks-mcp selftest" + ) + mock_run.assert_not_called() + + def test_cli_help_lists_each_option_without_internal_text(): """`--help` shows the summary and one line per option, not the Args section.""" import click