## 新能力: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 都是先被断言抓住、才避免了再次上线静默坏掉。
86 lines
4.5 KiB
JavaScript
86 lines
4.5 KiB
JavaScript
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 → 图像');
|
||
});
|