Files
nodeagent/tests/unit/win-prelude.test.mjs
T
jkooe 8ee8dc7f41 feat(v15): screen.find 图像模板匹配(补齐自绘 UI 盲区)+ 一串真机根因修复
## 新能力:method=image 图像模板匹配
审查报告 §5.2 指出「screen.find(image) 图像模板匹配未做,纯图标/无文字的控件是定位盲区」。
UIA 靠控件树、OCR 靠文字,两者对纯图标都无能为力;本版补上第三条腿。

实现(随常驻助手预加载,C# + System.Drawing,零新依赖):
- 灰度化(LockBits + ITU-R BT.601 整数近似)
- 零均值归一化互相关 ZNCC(对亮度/对比度变化不敏感)
- **粗到精**:1/4 分辨率全图扫描 → 候选在全分辨率 ±8px 精修
  (全分辨率逐像素在 3440x1440 上约 190 亿次,不可行)
- 非极大值抑制去重;返回中心坐标 + score,可直接喂 input.mouse.click
- 参数:template(被控端图片路径)、threshold(默认 0.85);支持 region 限定搜索区
- 出口:screen.find(method=image) + CLI/MCP 同步暴露

真机实测:3440x1440 全屏搜索 **~580ms**,score 0.9745 命中可用坐标;
对「模板纯色/低对比」会显式报错(E_PARAM_INVALID + variance),而不是静默返回空。

## 本轮为打通它而修掉的 7 个真机根因(都值得记档)
1. **预加载段拼接静默失败**:WIN_HELPER_PRELUDE 是多行模板串,替换串写成单行 \n 不匹配且
   无 assert → OCR/图像两段从未拼入 → 被 esbuild tree-shake → 运行时才报「找不到类型」。
   现已导出并加回归断言(四段齐全 + 顺序)。
2. **助手启动超命令行上限**:bootstrap 含内嵌 C#(编码后 ~36KB)超过 Windows ~32KB 限制,
   spawn 直接 ENAMETOOLONG → 每次静默降级到慢路径。改为落地临时脚本 + -File。
3. **一次性降级路径同样超限**:ENAMETOOLONG。同样改为临时脚本 + -File(UTF-8 BOM)。
4. **统计记账掩盖根因**:ensureStarted 在 try 之外,spawn 失败不计入 failures,
   表现为「spawned 一直涨、calls 恒为 0」。现已纳入 try,并暴露 ps_helper 状态
   (enabled/calls/hits/failures/spawned/avg_ms/lastError)到 system.info。
5. **Add-Type 在中文系统上乱码**:C# 源码按系统 ANSI(GBK) 落盘,中文注释必乱码 → 编译失败。
   已把内嵌 C# 全部改为纯 ASCII,并加断言「C# 块不得含非 ASCII」。
6. **Add-Type 缺 -ReferencedAssemblies**:只 Add-Type -AssemblyName System.Drawing 不够,
   编译期仍报「命名空间 System.Drawing 中不存在 Imaging」。已补 -ReferencedAssemblies。
7. **TS 模板串吃掉 \" 转义**:在模板字符串里写 \" 会得到裸引号 → C# 编译失败(踩了两次)。
   统一改用 C# 字符常量 Q 拼 JSON,并加断言(不得残留裸引号 JSON 形态 / Q 不得重复声明)。
另外把「粗层预筛」从 threshold-0.05 放宽到 threshold-0.30 并始终保留最优候选 ——
降采样会拉低真位置的分数,过严的预筛会让正确答案在精修前就被丢弃(这曾让所有匹配返回空)。

## 单测 105 -> 114
新增 win-prelude 回归 9 例:四段齐全与顺序 / C# 纯 ASCII / 无裸引号 JSON / Q 不重复声明 /
Win32+UIA+OCR+图像 各段关键标记(含 -ReferencedAssemblies)。
这些断言的价值在真机验证过:3 个 bug 都是先被断言抓住、才避免了再次上线静默坏掉。
2026-09-30 01:27:37 +08:00

86 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { WIN_HELPER_PRELUDE } from '../../apps/agent/dist/capabilities/window.js';
/**
* 回归测试:常驻助手的预加载段必须四段齐全。
*
* 为什么需要它:这些段是**多行模板字符串拼接**,一旦某次编辑没拼进去,
* 表现是「常量未被引用 → 被 esbuild tree-shake 掉 → 运行时才报找不到类型」,
* 而且不报编译错 —— 真机上就是这么静默坏掉的(OCR 与图像匹配同时失效)。
* 断言关键标记,任何一段丢失都会立刻失败。
*/
test('预加载段:Win32 C# 类型齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /public class NAWin32/, '缺少 Win32 P/Invoke 类型');
assert.match(WIN_HELPER_PRELUDE, /EnumWindows/, '缺 EnumWindows 声明');
});
test('预加载段:UIA 程序集与查找函数齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /Add-Type -AssemblyName UIAutomationClient/);
assert.match(WIN_HELPER_PRELUDE, /function Get-WinByTitle/);
assert.match(WIN_HELPER_PRELUDE, /function Find-UiaByText/, '缺子串查找函数(v14 起 body 依赖它)');
});
test('预加载段:OCR(含 WinRT 与 Await 辅助)齐全', () => {
assert.match(WIN_HELPER_PRELUDE, /Add-Type -AssemblyName System\.Windows\.Forms/);
assert.match(WIN_HELPER_PRELUDE, /OcrEngine, Windows\.Foundation, ContentType=WindowsRuntime/);
assert.match(WIN_HELPER_PRELUDE, /function global:Await/);
assert.match(WIN_HELPER_PRELUDE, /\$script:ocrReady/);
});
test('预加载段:图像模板匹配 C# 齐全且带 -ReferencedAssemblies', () => {
assert.match(WIN_HELPER_PRELUDE, /public class NAImageMatch/);
assert.match(WIN_HELPER_PRELUDE, /public static string Match\(/);
assert.match(WIN_HELPER_PRELUDE, /public static string Capture\(/);
// Add-Type 的坑:不显式引用程序集,编译期就找不到 System.Drawing.Imaging
assert.match(
WIN_HELPER_PRELUDE,
/-ReferencedAssemblies System\.Drawing/,
'缺 -ReferencedAssemblies:C# 编译会失败(真机踩过)',
);
});
test('预加载段:C# 源码必须纯 ASCII(否则 Add-Type 在中文系统上乱码)', () => {
// Add-Type 会把源码按**系统 ANSI 代码页**(中文 Windows = GBK)落盘再编译,
// C# 里的中文注释必然乱码 → 编译失败(真机踩过:整个图像匹配引擎静默不可用)。
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
assert.ok(csharpBlocks.length > 0, '应至少有一个内嵌 C# 块');
for (const [i, block] of csharpBlocks.entries()) {
const bad = [...new Set(block.match(/[^\x00-\x7f]/g) ?? [])];
assert.equal(
bad.length,
0,
`第 ${i + 1} 个 C# 块含非 ASCII 字符 ${bad.join('')} —— 必须改为英文注释`,
);
}
});
test('预加载段:C# 内不得残留裸引号 JSON 字面量(TS 模板串会把 \\" 吃成 ")', () => {
// 陷阱:在 TS 模板字符串里写 \\" 会得到裸 ",C# 收到未转义引号即编译失败。
// 正确做法是用 C# 字符常量 '"' 拼 JSON。这里断言几种典型坏形态不存在。
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
for (const block of csharpBlocks) {
assert.ok(!/\[\{"/.test(block), 'C# 中出现 "[" + "{" 的裸引号形态,应改用字符常量拼 JSON');
assert.ok(!/"\{[^"]*"/.test(block.replace(/"[^"]*"/g, '')), 'C# 字符串中出现未转义引号');
}
});
test('预加载段:C# 内局部常量不得重复声明(编辑残留会编译失败)', () => {
const csharpBlocks = WIN_HELPER_PRELUDE.match(/Add-Type @"[\s\S]*?"@/g) ?? [];
for (const block of csharpBlocks) {
const decls = block.match(/\bQ\s*=\s*'";/g) ?? [];
assert.ok(decls.length <= 1, `Q 被声明了 ${decls.length} 次 —— 重复定义会让 C# 编译失败`);
}
});
test('预加载段:顺序与完整性(四段都在同一个字符串里)', () => {
const idxWin32 = WIN_HELPER_PRELUDE.indexOf('public class NAWin32');
const idxUia = WIN_HELPER_PRELUDE.indexOf('function Get-WinByTitle');
const idxOcr = WIN_HELPER_PRELUDE.indexOf('OcrEngine, Windows.Foundation');
const idxImg = WIN_HELPER_PRELUDE.indexOf('public class NAImageMatch');
for (const [name, idx] of Object.entries({ idxWin32, idxUia, idxOcr, idxImg })) {
assert.ok(idx > 0, `${name} 未包含在预加载段中`);
}
assert.ok(idxWin32 < idxUia && idxUia < idxOcr && idxOcr < idxImg, '四段顺序应为 Win32 → UIA → OCR → 图像');
});