PRA-only-demo/packages/rpa-core/test/capabilities/cv-vision.test.ts

147 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
const detectMock = vi.hoisted(() => vi.fn(async () => ({
texts: [
{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] },
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
]
})));
// 屏幕服务模拟: 默认无画面(走 GDI 回退), 多屏用例按显示器 id/请求宽度提供对应画面
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
vi.mock("../driver/windows/powershell", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
import { getHandler } from "../../src/engine/registry";
import { setHostBridge, type HostDisplay } from "../../src/engine/host-bridge";
import "../../src/capabilities/cv-vision";
const displayOf = (id: string, x: number, width: number, height: number, scaleFactor: number): HostDisplay => ({
id,
bounds: { x, y: 0, width, height },
scaleFactor
});
const displays: HostDisplay[] = [
displayOf("1", 0, 1920, 1080, 1),
displayOf("2", 1920, 1280, 720, 2)
];
setHostBridge({
createFormWindow: () => { throw new Error("not used in cv-vision tests"); },
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
screenToDipPoint: (point) => ({ x: point.x, y: point.y }),
loadAiConfig: () => ({ baseUrl: "", apiKey: "", model: "", visionModel: "" }),
screen: {
listDisplays: () => displays,
primaryDisplay: () => displays[0],
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
displayMatching: () => displays[0],
captureDisplay: async (displayId: string, wantWidth: number, wantHeight: number) => {
// 模拟真实 captureDisplay 的对位规则,但不做 sources[0] 兜底:
// 保持"主屏无画面 → 跳过该屏"的旧多屏语义
const source = electronState.sources.find((item) => item.display_id === displayId && item.thumbnail.getSize().width === wantWidth)
|| electronState.sources.find((item) => item.thumbnail.getSize().width === wantWidth);
if (!source || source.thumbnail.isEmpty()) throw new Error(`无法获取显示器 ${displayId} 的屏幕截图`);
void wantHeight;
return source.thumbnail.toPNG();
}
}
});
describe("CV vision capabilities", () => {
beforeEach(() => {
powershellMock.mockClear();
detectMock.mockClear();
electronState.sources = [];
});
it.each(["CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick"])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy();
});
it("rejects an empty CV selector", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n1", componentId: "CV_GetText" } as any, {}, {} as any))
.rejects.toThrow("CV 目标为空");
});
it("locates OCR text and clicks its center with offset", async () => {
const handler = getHandler("CV_MouseClick")!;
await expect(handler({ log: vi.fn() } as any, { id: "n1", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
offsetX: 5,
offsetY: 2
}, {} as any)).resolves.toBe(true);
expect(detectMock).toHaveBeenCalledTimes(1);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(15, 7)");
expect(script).toContain("mouse_event(0x0002");
});
it("returns matched OCR text", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n2", componentId: "CV_GetText" } as any, {
cvSelector: "保存"
}, {} as any)).resolves.toBe("确认保存");
expect(powershellMock).toHaveBeenCalledTimes(1);
});
it("restricts matching to the search region when provided", async () => {
// 屏幕上有两处「确认」: 区域(0,0,50,50)内只应命中 (5,5) 那处, 而不是第一个全屏命中 (115,205)
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n3", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 0, y: 0, width: 50, height: 50 })
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(10, 5)");
});
it("rejects when target is outside the search region", async () => {
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n4", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 500, y: 500, width: 50, height: 50 })
}, {} as any)).rejects.toThrow("未找到目标文本");
});
it("locates targets on a secondary display and maps DIP coordinates", async () => {
// 主屏无画面, 副屏(bounds 1920,0 1280x720 缩放2)物理中心(450,210) → DIP(2145,105)
const fakeThumb = (w: number, h: number): any => ({
isEmpty: () => false,
getSize: () => ({ width: w, height: h }),
toPNG: () => Buffer.from([137, 80, 78, 71])
});
electronState.sources = [{ display_id: "2", thumbnail: fakeThumb(2560, 1440) }];
detectMock.mockImplementationOnce(async () => ({
texts: [{ text: "副屏按钮", box: [[400, 200], [500, 200], [500, 220], [400, 220]] }]
}));
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n5", componentId: "CV_MouseClick" } as any, {
cvSelector: "副屏按钮"
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
expect(script).toContain("SetCursorPos(2145, 105)");
});
it("maps region-image hits through the display scale", async () => {
// 框选图来自缩放2的副屏: 图内物理中心(120,90) + 框选DIP原点(1920,0) → DIP(1980,45)
const regionShot = path.join(os.tmpdir(), "rpa-cv-region-test-" + Date.now() + ".png");
fs.writeFileSync(regionShot, Buffer.from([137, 80, 78, 71]));
try {
detectMock.mockImplementationOnce(async () => ({
texts: [{ text: "确认", box: [[100, 80], [140, 80], [140, 100], [100, 100]] }]
}));
await expect(getHandler("CV_MouseHover")!({ log: vi.fn() } as any, { id: "n6", componentId: "CV_MouseHover" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 1920, y: 0, width: 640, height: 360, scale: 2, imagePath: regionShot })
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
expect(script).toContain("SetCursorPos(1980, 45)");
} finally {
try { fs.unlinkSync(regionShot); } catch {}
}
});
});