From 3ea378185cd5ba619e09aa25b3007ec7847d87e1 Mon Sep 17 00:00:00 2001
From: Raylan LIN
Date: Fri, 2 Oct 2026 07:01:42 +0000
Subject: [PATCH] fix: P134 set_material verifies (zh/en names), run_macro
binds swApp/Part and reports where it stopped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
set_material: SetMaterialPropertyName2 returns nothing and silently ignores an
unknown name (a Chinese install has 合金钢, not "Alloy Steel"), so the tool reported
success while the tree said 材质 <未指定> and masses came out at water density. Each
candidate (given name, then its zh/en alias, both database spellings) is now applied
and verified by reading the density back; nothing taking is an error.
run_macro: the description promised swApp and Part were bound, but nothing assigned
them, and a runtime error inside the entry Sub reached the runner with Err clear, so
broken macros "completed". The entry Sub now binds swApp/Part/swModel when used,
records line number + text before each statement (skipping continuation lines and
the first line after Select Case) and sets a completion flag at its end and on every
Exit Sub; a macro that did not reach the end fails with the line it stopped on.
Connection note: first result only, and distinguishes not-running / background
instance shown / second instance started.
201 JS + 67 Python tests pass; typecheck, lint, ruff, compileall OK.
---
CHANGELOG.md | 46 ++++++++++++++
README.md | 8 +--
README.zh-CN.md | 8 +--
package.json | 2 +-
sidecar/sw_agent/bridge.py | 6 +-
sidecar/sw_agent/server.py | 3 +-
sidecar/sw_agent/tools/document.py | 87 ++++++++++++++++++++++++++-
sidecar/tests/test_p133_visibility.py | 73 +++++++++++++++++++++-
src/main/agent/agent-loop-sidecar.ts | 5 +-
src/main/scripts/vba-macro-writer.ts | 74 ++++++++++++++++++++++-
tests/vba-macro-writer.test.mjs | 33 ++++++++++
11 files changed, 328 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7fb9dd..c69be07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,52 @@
## [Unreleased]
+## [0.2.133] - 2026-10-02
+
+### Fixed (P134 — material that never took, macros that could not fail)
+
+Seen in a real session: the part was built correctly and was visible. But the tree still
+said 材质 <未指定> after `set_material` "succeeded", every mass came out at water density, and
+the agent then spent about ten `run_macro` calls on macros that all "completed" without
+doing anything.
+
+**`set_material` reported success when nothing was applied.**
+- **Why:** `SetMaterialPropertyName2` returns nothing, and SolidWorks silently ignores a name
+ it does not know. A Chinese install has 「合金钢」, not "Alloy Steel".
+- **Fix:** the tool now applies each candidate — the given name, then its zh/en alias (Alloy
+ Steel ↔ 合金钢, Plain Carbon Steel ↔ 普通碳钢, 6061 Alloy ↔ 6061 合金, …; "steel" /
+ "钢" / "aluminum" / "铝" map to a standard grade) — in both database spellings, and
+ verifies each one by reading the density back.
+- **Result:** if nothing takes, the tool raises an error naming what it tried. If the
+ density is unchanged on a part that already has a material, it returns `verified:false`
+ with a note instead of claiming success.
+
+**`run_macro` bound nothing.** The tool description promised that `swApp` and `Part` are
+bound, but nothing assigned them, so every macro touching SolidWorks died on its first
+statement. The entry `Sub` now binds `swApp` / `Part` / `swModel` (only when the macro uses
+them).
+
+**`run_macro` reported success after a runtime error.** An error inside the entry `Sub`
+reached the runner with `Err` already clear, so a macro that stopped on its first broken
+line came back "脚本执行完成".
+- **Fix:** the entry `Sub` is instrumented. Before each statement it records the line number
+ and text (no marker after a ` _` continuation or between `Select Case` and its first
+ `Case`), and it sets a completion flag on its last line and on every `Exit Sub`.
+- **Result:** a macro that did not reach the end is reported as failed, with the line it
+ stopped on.
+
+**Connection note:** it is now attached to the first result only (it was repeated on every
+result), and it distinguishes "SolidWorks was not running" from "a background instance
+without a window was shown" from "a second instance was started".
+
+### Tests
+- **`tests/vba-macro-writer.test.mjs`:** binding, completion flag and line reporting, no
+ marker where VBScript forbids a statement, Functions-only scripts.
+- **`sidecar/tests/test_p133_visibility.py`:** `set_material` falls back to the Chinese
+ name and verifies; a material that never takes is an error; an ambiguous case is not
+ claimed; the background-instance note.
+- **Totals:** 201 JS + 67 Python tests.
+
## [0.2.132] - 2026-10-02
### Fixed (P133 — tools built the part in a SolidWorks the user could not see)
diff --git a/README.md b/README.md
index edb69ca..1d75fe7 100644
--- a/README.md
+++ b/README.md
@@ -28,12 +28,12 @@
-
+
-
+
@@ -86,7 +86,7 @@ Millwright:
- **Agentic tool loop.** Observe → reason → act. The model chains multiple tool calls, reads structured JSON back from each one, and recovers from errors instead of failing silently.
- **Visual understanding.** Reorient, rotate, screenshot, and analyze the model — via a multimodal main model or a dedicated vision model.
- **Resident execution engine.** A persistent Python sidecar holds one COM connection open across an entire multi-step task.
-- **Developer-friendly.** 197 TypeScript/Node tests plus a Python suite (`pytest sidecar/tests`) for the sidecar, a typed IPC boundary, and a `SKIP_SW_CONNECT` mode for UI-only development without SolidWorks installed.
+- **Developer-friendly.** 201 TypeScript/Node tests plus a Python suite (`pytest sidecar/tests`) for the sidecar, a typed IPC boundary, and a `SKIP_SW_CONNECT` mode for UI-only development without SolidWorks installed.
## Cross-version compatibility
@@ -248,7 +248,7 @@ Contributions welcome — see [CONTRIBUTING.md](docs/CONTRIBUTING.md). We especi
- [x] **v0.1** — MVP: Electron shell, LLM adapters, COM bridge, first tool set
- [x] **v0.2** — Python sidecar, agentic tool loop, dual-engine fallback, vision feedback, confirmation cards, Apache-2.0 open source
-- [x] **v0.2.4 → v0.2.132** — Extensive hardening against real SolidWorks installs ← *current*: the sketch → feature → cut → visual-verification loop now runs end to end on real hardware
+- [x] **v0.2.4 → v0.2.133** — Extensive hardening against real SolidWorks installs ← *current*: the sketch → feature → cut → visual-verification loop now runs end to end on real hardware
- [ ] **v0.3** — Streaming tool calls, sketching on model faces (not just reference planes), hole wizard, sheet metal, drawing annotations, remaining `#VERIFY` parameters confirmed
- [ ] **v1.0** — MCP server, multi-CAD support
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ee73d31..0cfe97c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -27,12 +27,12 @@
-
+
-
+
@@ -85,7 +85,7 @@ Millwright:
- **Agent 工具循环。** 观察 → 推理 → 执行。模型串联多次工具调用,读取每次返回的结构化 JSON,出错能自愈而不是静默失败。
- **视觉理解。** 可翻转、旋转、截屏,再做分析——既支持多模态主模型,也支持独立视觉模型。
- **常驻执行引擎。** 常驻 Python 边车在一整个多步任务中复用同一条 COM 连接。
-- **开发者友好。** 197 个 TS/Node 单元测试,另有独立的 Python 测试套件(`pytest sidecar/tests`),类型化 IPC 边界,`SKIP_SW_CONNECT` 纯 UI 开发模式(无需 SolidWorks)。
+- **开发者友好。** 201 个 TS/Node 单元测试,另有独立的 Python 测试套件(`pytest sidecar/tests`),类型化 IPC 边界,`SKIP_SW_CONNECT` 纯 UI 开发模式(无需 SolidWorks)。
## 跨版本兼容
@@ -247,7 +247,7 @@ SolidWorks
- [x] **v0.1** — MVP:Electron 骨架、LLM 适配器、COM 桥接、首批工具
- [x] **v0.2** — Python 边车、agent 工具循环、双引擎降级、视觉反馈、确认卡片,Apache-2.0 开源
-- [x] **v0.2.4 → v0.2.132** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通
+- [x] **v0.2.4 → v0.2.133** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通
- [ ] **v0.3** — 流式工具调用、在模型面上画草图(而非仅基准面)、孔向导、钣金、工程图标注、剩余 `# VERIFY` 参数完成核验
- [ ] **v1.0** — MCP server、多 CAD 支持
diff --git a/package.json b/package.json
index b461744..5fc97d6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "millwright",
- "version": "0.2.132",
+ "version": "0.2.133",
"description": "Open-source AI automation for SolidWorks — talk to your CAD.",
"keywords": [
"solidworks",
diff --git a/sidecar/sw_agent/bridge.py b/sidecar/sw_agent/bridge.py
index 183b998..5fe4bab 100644
--- a/sidecar/sw_agent/bridge.py
+++ b/sidecar/sw_agent/bridge.py
@@ -145,7 +145,11 @@ def ensure_visible(app, pids_before: set[int] | None) -> str | None:
after = _sw_pids()
second = bool(pids_before) and after is not None and bool(after - pids_before)
if not second:
- return ("SolidWorks 之前没有可见窗口,已启动/显示 SolidWorks;之后的建模都在这个窗口里进行。")
+ if pids_before: # attached to a SolidWorks already running in the background
+ return ("连接到的是一个在后台运行、没有窗口的 SolidWorks(可能是之前残留的进程),已把它显示出来,"
+ "之后的建模都在这个窗口里。如果你还开着另一个 SolidWorks 窗口,请以这个新显示的为准,"
+ "或关掉多余的那个。")
+ return "SolidWorks 之前没有运行,已为你启动并显示,之后的建模都在这个窗口里。"
note = ("你已打开的 SolidWorks 无法被连接,系统因此新启动了一个 SolidWorks 实例(现已显示出来),"
"模型会建在这个新窗口里,而不是你原来的窗口。通常原因是两者权限级别不同——其中一个是"
"「以管理员身份运行」的。")
diff --git a/sidecar/sw_agent/server.py b/sidecar/sw_agent/server.py
index 00d2b11..1dd8428 100644
--- a/sidecar/sw_agent/server.py
+++ b/sidecar/sw_agent/server.py
@@ -207,9 +207,10 @@ def work():
except Exception: # noqa: BLE001
pass
# P133: we are driving a SolidWorks the user did not open (see
- # bridge.ensure_visible) — say so on every result until the session reconnects
+ # bridge.ensure_visible) — say so ONCE, on the first result after connecting
if getattr(ctx, "connect_note", None):
data["_connection"] = ctx.connect_note
+ ctx.connect_note = None
if mutating:
adv = _advisory(ctx)
if adv:
diff --git a/sidecar/sw_agent/tools/document.py b/sidecar/sw_agent/tools/document.py
index b0297ad..6fb077d 100644
--- a/sidecar/sw_agent/tools/document.py
+++ b/sidecar/sw_agent/tools/document.py
@@ -249,10 +249,91 @@ def save_as(ctx: Context, path: str):
category="document",
)
def set_material(ctx: Context, material: str, database: str = ""):
+ """P133: SetMaterialPropertyName2 returns nothing, and an unknown name is silently
+ ignored — a Chinese SolidWorks has 「合金钢」, not "Alloy Steel". The tool used to report
+ success while the tree still said 材质 <未指定> and every mass came out at water
+ density. Now each candidate (the given name, then its zh/en alias) is applied and
+ VERIFIED by reading the density back; nothing taking is an error, not a success."""
part = ctx.require(DOC_PART, "part")
- db = database or "SOLIDWORKS Materials"
- part.SetMaterialPropertyName2("", db, material)
- return {"material": material, "database": db}
+ dbs = [database] if database else ["SOLIDWORKS Materials", "solidworks materials"]
+ names = _material_candidates(material)
+ before = _density(ctx)
+ tried = []
+ for db in dbs:
+ for name in names:
+ tried.append(f"{db}/{name}")
+ try:
+ part.SetMaterialPropertyName2("", db, name)
+ except Exception: # noqa: BLE001 — try the next spelling
+ continue
+ try:
+ ctx.model.ForceRebuild3(False)
+ except Exception: # noqa: BLE001
+ pass
+ after = _density(ctx)
+ if after is None: # cannot read back on this install — report, do not claim
+ return {"material": name, "database": db, "verified": False}
+ if _changed(before, after):
+ return {"material": name, "database": db, "verified": True,
+ "density_kg_m3": round(after, 3)}
+ if before is not None and not _changed(_NO_MATERIAL_DENSITY, before):
+ continue # still the no-material density: the name did not take
+ # the part already had a material of this density — re-applying the same one,
+ # or a name SolidWorks ignored; the density cannot tell which
+ return {"material": name, "database": db, "verified": False,
+ "density_kg_m3": round(after, 3),
+ "note": "density unchanged — either this material was already applied, or "
+ "the name was not recognised; check the material in the feature tree"}
+ raise SWError(
+ f"material '{material}' was not applied — SolidWorks did not recognise the name "
+ f"(tried {', '.join(tried)}; density still {before} kg/m³). Use the exact name from "
+ "SolidWorks' material dialog — a Chinese install names them e.g. 合金钢 / 普通碳钢 / 6061 合金.")
+
+
+# P133: English ↔ Chinese names of SolidWorks' standard materials (+ generic words)
+_MATERIAL_ALIASES: list[tuple[str, ...]] = [
+ ("Alloy Steel", "合金钢"),
+ ("Plain Carbon Steel", "普通碳钢"),
+ ("Cast Carbon Steel", "铸造碳钢"),
+ ("Cast Alloy Steel", "铸造合金钢"),
+ ("1060 Alloy", "1060 合金"),
+ ("6061 Alloy", "6061 合金"),
+ ("Plain Carbon Steel", "普通碳钢", "steel", "钢"),
+ ("6061 Alloy", "6061 合金", "aluminum", "aluminium", "铝", "铝合金"),
+]
+
+_NO_MATERIAL_DENSITY = 1000.0 # SolidWorks' density for a part with no material assigned
+
+
+def _material_candidates(material: str) -> list[str]:
+ m = material.strip()
+ low = m.lower()
+ out = [m]
+ for group in _MATERIAL_ALIASES:
+ if low in (g.lower() for g in group):
+ out += [g for g in group[:2] if g not in out]
+ return out
+
+
+def _density(ctx) -> float | None:
+ """kg/m³ from IMassProperty, or None when it cannot be read."""
+ try:
+ ext = ctx.model.Extension
+ for maker in ("CreateMassProperty", "CreateMassProperty2"):
+ try:
+ mp = sw_get(ext, maker)
+ except Exception: # noqa: BLE001
+ continue
+ if mp is not None:
+ return float(sw_get(mp, "Density"))
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
+
+def _changed(before: float | None, after: float) -> bool:
+ ref = _NO_MATERIAL_DENSITY if before is None else before
+ return abs(after - ref) > 1e-6
@tool("rebuild_model", "Force a full model rebuild, i.e. Ctrl+Q (= Rebuild; ForceRebuild3)", params={}, category="document")
diff --git a/sidecar/tests/test_p133_visibility.py b/sidecar/tests/test_p133_visibility.py
index b26d266..31ea059 100644
--- a/sidecar/tests/test_p133_visibility.py
+++ b/sidecar/tests/test_p133_visibility.py
@@ -64,4 +64,75 @@ def test_hidden_only_instance_is_shown(monkeypatch):
app = _App(False)
note = bridge.ensure_visible(app, set()) # SolidWorks was not running before
assert app.Visible is True
- assert "新启动" not in note
+ assert "没有运行" in note
+
+
+def test_hidden_background_instance_is_shown(monkeypatch):
+ monkeypatch.setattr(bridge, "_sw_pids", lambda: {100})
+ app = _App(False)
+ note = bridge.ensure_visible(app, {100}) # same process, it just had no window
+ assert app.Visible is True
+ assert "后台" in note
+
+
+# ---- set_material: verified by density, zh/en aliases ----
+
+_DENSITY = {"合金钢": 7700.0, "普通碳钢": 7800.0, "6061 合金": 2700.0}
+
+
+class _MatPart:
+ """A Chinese SolidWorks: only Chinese names exist; unknown names are silently ignored."""
+
+ def __init__(self, material=None):
+ self.material = material
+ self.calls = []
+
+ def SetMaterialPropertyName2(self, cfg, db, name):
+ self.calls.append(name)
+ if name in _DENSITY:
+ self.material = name
+
+ def ForceRebuild3(self, _):
+ return True
+
+
+def _mat_ctx(part):
+ class Mp:
+ _oleobj_ = object()
+
+ @property
+ def Density(self):
+ return _DENSITY.get(part.material, 1000.0)
+
+ class Ext:
+ _oleobj_ = object()
+ CreateMassProperty = Mp()
+
+ class Ctx:
+ model = part
+ def require(self, *_):
+ return part
+ part.Extension = Ext()
+ return Ctx()
+
+
+def test_set_material_falls_back_to_the_chinese_name_and_verifies():
+ from sw_agent.tools import document
+ part = _MatPart()
+ out = document.set_material(_mat_ctx(part), "Alloy Steel")
+ assert part.material == "合金钢"
+ assert out["material"] == "合金钢" and out["verified"] is True and out["density_kg_m3"] == 7700.0
+
+
+def test_set_material_that_never_takes_is_an_error():
+ import pytest
+ from sw_agent.bridge import SWError
+ from sw_agent.tools import document
+ with pytest.raises(SWError, match="not applied"):
+ document.set_material(_mat_ctx(_MatPart()), "Unobtainium")
+
+
+def test_set_material_unknown_name_on_a_part_with_material_is_not_claimed():
+ from sw_agent.tools import document
+ out = document.set_material(_mat_ctx(_MatPart("普通碳钢")), "Unobtainium")
+ assert out["verified"] is False and "unchanged" in out["note"]
diff --git a/src/main/agent/agent-loop-sidecar.ts b/src/main/agent/agent-loop-sidecar.ts
index eaa3417..a6b3637 100644
--- a/src/main/agent/agent-loop-sidecar.ts
+++ b/src/main/agent/agent-loop-sidecar.ts
@@ -136,7 +136,10 @@ const VIRTUAL_TOOLS = [
+ 'accepts, pick faces and edges by meaning, and report the REAL error instead of '
+ 'silently doing nothing. A static check runs first and REFUSES code that passes '
+ 'millimetres to metre-based APIs or opens with On Error Resume Next. '
- + 'Write the body only — swApp and Part are already bound, no Sub main() wrapper.',
+ + 'Write the body only — no Sub main() wrapper. swApp (the SolidWorks application), '
+ + 'Part and swModel (both = the active document) are bound for you. A runtime error '
+ + 'stops the macro and is reported with the line it stopped on; a macro has no other '
+ + 'way to return data than WScript.Echo, which comes back as the tool output.',
parameters: {
type: 'object',
properties: {
diff --git a/src/main/scripts/vba-macro-writer.ts b/src/main/scripts/vba-macro-writer.ts
index 5dfcb96..a4411ea 100644
--- a/src/main/scripts/vba-macro-writer.ts
+++ b/src/main/scripts/vba-macro-writer.ts
@@ -136,6 +136,11 @@ export function vbaToVbs(vbaCode: string, opts?: { resultFilePath?: string }): s
}
// 只有 Function 没有 Sub 的罕见情形: 顶层代码原样执行,entry 留空
+ // 9b. P133: 给入口 Sub 打桩 —— 预绑定 swApp/Part/swModel,逐行记录位置,末尾置完成标志。
+ const usesSw = /\b(swApp|Part|swModel)\b/i.test(vbaCode);
+ const instrumented = entry ? instrumentEntry(code, entry, usesSw) : null;
+ if (instrumented) code = instrumented;
+
// 10. 组装完整 VBS
const resultPath = opts?.resultFilePath ?? '';
// VBS 字符串没有反斜杠转义,路径直接嵌入;只需防御性处理双引号
@@ -148,6 +153,10 @@ export function vbaToVbs(vbaCode: string, opts?: { resultFilePath?: string }): s
Dim SWCP_RESULT_PATH
SWCP_RESULT_PATH = "${resultPathLiteral}"
Dim SWCP_APP
+Dim SWCP_LN, SWCP_AT, SWCP_DONE
+SWCP_LN = 0
+SWCP_AT = ""
+SWCP_DONE = ${instrumented ? 'False' : 'True'}
`;
const runner = entry
@@ -160,7 +169,13 @@ If Err.Number <> 0 Then
Dim SWCP_ERRDESC
SWCP_ERRDESC = Err.Description
If SWCP_ERRDESC = "" Then SWCP_ERRDESC = "未知错误 (代码 " & Err.Number & ")"
- SWCP_Fail "脚本执行出错: " & SWCP_ERRDESC
+ SWCP_Fail "脚本执行出错" & SWCP_Where() & ": " & SWCP_ERRDESC
+End If
+' P133: a runtime error inside the entry Sub can reach here with Err already clear — the
+' macro then "succeeded" while it had stopped at its first broken line. The completion
+' flag set by the instrumented last line is the evidence that it ran to the end.
+If Not SWCP_DONE Then
+ SWCP_Fail "脚本没有执行到最后一行" & SWCP_Where() & "。这是 VBScript 运行时错误(常见原因:对象为空/未绑定、方法或属性不存在、参数个数不对)。"
End If
On Error GoTo 0
SWCP_WriteResult True, "脚本执行完成"
@@ -200,6 +215,15 @@ Function SWCP_ConnectSW()
Set SWCP_ConnectSW = SWCP_APP
End Function
+' P133: 出错位置(入口 Sub 内第几行 + 该行代码)
+Function SWCP_Where()
+ If SWCP_LN > 0 Then
+ SWCP_Where = ",停在第 " & SWCP_LN & " 行: " & SWCP_AT
+ Else
+ SWCP_Where = ""
+ End If
+End Function
+
' 写执行结果 JSON。Unicode=True → UTF-16LE+BOM,中文消息不乱码。
Sub SWCP_WriteResult(ok, msg)
On Error Resume Next
@@ -246,6 +270,54 @@ ${code}
${runner}${supportLib}`;
}
+/**
+ * P133: instrument the entry Sub.
+ * - swApp / Part / swModel are bound first when the macro uses them — the run_macro tool
+ * promised they were, but nothing assigned them, so every macro touching SolidWorks
+ * died on its first statement.
+ * - Before each statement: SWCP_LN / SWCP_AT record where we are, so a failure names the
+ * line. Skipped where VBScript allows no statement: continuation lines (previous line
+ * ends in " _") and the first line after "Select Case".
+ * - Last line (and every Exit Sub): SWCP_DONE = True — proof the body ran to the end.
+ * Returns null when the entry Sub cannot be located (the runner then trusts Err alone).
+ */
+function instrumentEntry(code: string, entry: string, bindSw: boolean): string | null {
+ const lines = code.split('\n');
+ const head = new RegExp(`^\\s*Sub\\s+${entry}\\s*\\(`, 'i');
+ const start = lines.findIndex((l) => head.test(l));
+ if (start < 0) return null;
+ let end = -1;
+ for (let i = start + 1; i < lines.length; i++) {
+ if (/^\s*End\s+Sub\b/i.test(lines[i])) { end = i; break; }
+ }
+ if (end < 0) return null;
+
+ const out: string[] = [];
+ if (bindSw) {
+ out.push(' Set swApp = SWCP_ConnectSW()', ' Set Part = swApp.ActiveDoc', ' Set swModel = Part');
+ }
+ let n = 0;
+ let prev = '';
+ for (const line of lines.slice(start + 1, end)) {
+ const t = line.trim();
+ const significant = t !== '' && !t.startsWith("'") && !/^rem\b/i.test(t);
+ if (significant) {
+ n++;
+ const afterContinuation = /\s_\s*$/.test(prev);
+ const afterSelect = /^\s*Select\s+Case\b/i.test(prev);
+ if (!afterContinuation && !afterSelect) {
+ const indent = /^\s*/.exec(line)![0];
+ const text = t.length > 120 ? t.slice(0, 117) + '...' : t;
+ out.push(`${indent}SWCP_LN = ${n}: SWCP_AT = "${text.replace(/"/g, '""')}"`);
+ }
+ prev = line;
+ }
+ out.push(line.replace(/\bExit\s+Sub\b/gi, 'SWCP_DONE = True: Exit Sub'));
+ }
+ out.push(' SWCP_DONE = True');
+ return [...lines.slice(0, start + 1), ...out, ...lines.slice(end)].join('\n');
+}
+
/**
* 检查 VBA 代码中无法转换为 VBScript 的语法。
* 返回问题列表(空 = 兼容)。engine 在执行前调用,
diff --git a/tests/vba-macro-writer.test.mjs b/tests/vba-macro-writer.test.mjs
index 4a84032..88f3287 100644
--- a/tests/vba-macro-writer.test.mjs
+++ b/tests/vba-macro-writer.test.mjs
@@ -168,3 +168,36 @@ test('checkVbsCompatibility 合法代码无误报', () => {
const issues = checkVbsCompatibility(WRAPPED_VBA);
assert.equal(issues.length, 0, `不应有兼容性问题: ${issues.join(', ')}`);
});
+
+// P133: run_macro promised "swApp and Part are already bound" — nothing bound them, and a
+// runtime error inside the entry Sub still ended as "脚本执行完成".
+test('P133: swApp / Part / swModel are bound when the macro uses them', () => {
+ const vbs = vbaToVbs('Debug = Part.GetTitle', { resultFilePath: 'C:\\r.json' });
+ assert.match(vbs, /Set swApp = SWCP_ConnectSW\(\)\n\s*Set Part = swApp\.ActiveDoc\n\s*Set swModel = Part/);
+ const plain = vbaToVbs('x = 1 + 1', { resultFilePath: 'C:\\r.json' });
+ assert.doesNotMatch(plain, /Set Part = /, 'no SolidWorks connection for a macro that does not touch it');
+});
+
+test('P133: completion flag + failing line are reported', () => {
+ const vbs = vbaToVbs('a = 1\nb = Part.GetTitle', { resultFilePath: 'C:\\r.json' });
+ assert.match(vbs, /SWCP_DONE = False/);
+ assert.match(vbs, /SWCP_LN = 2: SWCP_AT = "b = Part\.GetTitle"\n\s*b = Part\.GetTitle/);
+ assert.match(vbs, /SWCP_DONE = True\nEnd Sub/);
+ assert.match(vbs, /If Not SWCP_DONE Then\n\s*SWCP_Fail/);
+});
+
+test('P133: no marker where VBScript allows no statement; Exit Sub still completes', () => {
+ const vbs = vbaToVbs(
+ 'Select Case k\n Case 1\n y = 1\nEnd Select\ns = "a" & _\n "b"\nIf z Then Exit Sub',
+ { resultFilePath: 'C:\\r.json' },
+ );
+ assert.match(vbs, /Select Case k\n\s*Case 1/, 'nothing between Select Case and the first Case');
+ assert.match(vbs, /s = "a" & _\n\s*"b"/, 'a continued line is not split');
+ assert.match(vbs, /If z Then SWCP_DONE = True: Exit Sub/);
+});
+
+test('P133: a script with only Functions is not marked as unfinished', () => {
+ const vbs = vbaToVbs('Function f()\n f = 1\nEnd Function', { resultFilePath: 'C:\\r.json' });
+ assert.match(vbs, /SWCP_DONE = True\n/);
+ assert.doesNotMatch(vbs, /SWCP_DONE = False/);
+});