Skip to content

Commit f4cdd7f

Browse files
committed
feat(window-recognition): 窗口信号层(HWND解析/几何计算,注入式可测)
1 parent 81cbd42 commit f4cdd7f

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// @vitest-environment node
2+
/**
3+
* 窗口信号层测试(注入式:native 数据由调用方传入,本模块不依赖 electron)
4+
* @ai-context: 覆盖 HWND 解析、native 索引构建、几何信号计算、信号缺失降级。
5+
*/
6+
import { describe, it, expect } from 'vitest';
7+
import { parseHwndFromSourceId, buildNativeIndex, resolveGeometrySignals } from './windowSignals.js';
8+
9+
describe('parseHwndFromSourceId', () => {
10+
it('解析标准 desktopCapturer id', () => {
11+
expect(parseHwndFromSourceId('window:123456:0')).toBe('123456');
12+
});
13+
it('非 window 前缀返回 null', () => {
14+
expect(parseHwndFromSourceId('screen:0:0')).toBeNull();
15+
expect(parseHwndFromSourceId('')).toBeNull();
16+
expect(parseHwndFromSourceId(null as unknown as string)).toBeNull();
17+
});
18+
it('畸形 id 返回 null', () => {
19+
expect(parseHwndFromSourceId('window:')).toBeNull();
20+
expect(parseHwndFromSourceId('window:abc:0')).toBe('abc');
21+
});
22+
});
23+
24+
describe('buildNativeIndex', () => {
25+
it('按 hwnd 字符串建索引', () => {
26+
const idx = buildNativeIndex([
27+
{ hwnd: '111', processName: 'chrome.exe', width: 1280, height: 720, alwaysOnTop: false },
28+
]);
29+
expect(idx.get('111')?.processName).toBe('chrome.exe');
30+
expect(idx.size).toBe(1);
31+
});
32+
it('空输入返回空 Map', () => {
33+
expect(buildNativeIndex([]).size).toBe(0);
34+
});
35+
});
36+
37+
describe('resolveGeometrySignals', () => {
38+
it('计算宽高比与面积占比', () => {
39+
const s = resolveGeometrySignals(
40+
{ hwnd: '1', processName: 'chrome.exe', width: 1280, height: 720, alwaysOnTop: true },
41+
1920 * 1080,
42+
);
43+
expect(s.aspectRatio).toBeCloseTo(16 / 9, 5);
44+
expect(s.areaRatio).toBeCloseTo(1280 * 720 / (1920 * 1080), 5);
45+
expect(s.alwaysOnTop).toBe(true);
46+
});
47+
it('native 缺失(undefined)时返回空信号(降级)', () => {
48+
expect(resolveGeometrySignals(undefined, 1920 * 1080)).toEqual({});
49+
});
50+
it('显示器面积为 0 时避免除零', () => {
51+
const s = resolveGeometrySignals(
52+
{ hwnd: '1', processName: 'x.exe', width: 100, height: 100, alwaysOnTop: false },
53+
0,
54+
);
55+
expect(s.areaRatio).toBeUndefined();
56+
});
57+
});

‎client/electron/windowSignals.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* 窗口信号采集(注入式纯函数层)
3+
*
4+
* 将 desktopCapturer source id 与 native 窗口枚举(listAudioWindows)对齐,
5+
* 产出评分所需的进程名/几何信号。native 数据由调用方(screenCaptureHandlers)
6+
* 经 loadProcessAudioNative 获取后注入——本模块不依赖 electron,可单测。
7+
*
8+
* @ai-context: source id 形如 "window:<HWND>:0",HWND 用字符串传递(可能超
9+
* 2^32,见 addon.cc)。native 缺失时降级为空信号(纯标题评分路径,与现状一致)。
10+
* @ai-context EN: Pure signal-resolution layer. Callers inject native window
11+
* enumeration results; missing native degrades to title-only scoring.
12+
*/
13+
14+
// ================================================================
15+
// 类型定义
16+
// ================================================================
17+
18+
/** native 枚举窗口的最小信号子集(对齐 processAudioNative.NativeWindowInfo 扩展后字段) */
19+
export interface NativeWindowSignal {
20+
hwnd: string;
21+
processName: string;
22+
width: number;
23+
height: number;
24+
alwaysOnTop: boolean;
25+
}
26+
27+
/** 评分所需几何信号(全部可选,缺失即不参与) */
28+
export interface GeometrySignals {
29+
aspectRatio?: number;
30+
areaRatio?: number;
31+
alwaysOnTop?: boolean;
32+
}
33+
34+
// ================================================================
35+
// 信号解析
36+
// ================================================================
37+
38+
/**
39+
* 从 desktopCapturer source id 解析 HWND。
40+
* @param sourceId 形如 "window:<HWND>:0";非 window 前缀或结构缺失返回 null
41+
*/
42+
export function parseHwndFromSourceId(sourceId: string | null): string | null {
43+
if (!sourceId || !sourceId.startsWith('window:')) return null;
44+
const parts = sourceId.split(':');
45+
if (parts.length < 2 || !parts[1]) return null;
46+
return parts[1];
47+
}
48+
49+
/**
50+
* 按 hwnd 字符串建索引,供 source id → native 窗口 O(1) 匹配。
51+
*/
52+
export function buildNativeIndex(
53+
nativeWindows: NativeWindowSignal[],
54+
): Map<string, NativeWindowSignal> {
55+
const index = new Map<string, NativeWindowSignal>();
56+
for (const w of nativeWindows) {
57+
index.set(w.hwnd, w);
58+
}
59+
return index;
60+
}
61+
62+
/**
63+
* 由 native 窗口 + 显示器面积计算几何信号。
64+
* @param native 匹配到的 native 窗口;undefined 表示未匹配/信号源缺失
65+
* @param displayArea 显示器面积(px²,screen 模块计算);0 时面积占比置空避免除零
66+
*/
67+
export function resolveGeometrySignals(
68+
native: NativeWindowSignal | undefined,
69+
displayArea: number,
70+
): GeometrySignals {
71+
if (!native) return {};
72+
const signals: GeometrySignals = {
73+
alwaysOnTop: native.alwaysOnTop,
74+
};
75+
if (native.width > 0 && native.height > 0) {
76+
signals.aspectRatio = native.width / native.height;
77+
}
78+
if (displayArea > 0 && native.width > 0 && native.height > 0) {
79+
signals.areaRatio = (native.width * native.height) / displayArea;
80+
}
81+
return signals;
82+
}

0 commit comments

Comments
 (0)