Files
nodeagent/tests/unit/hotkeys.test.mjs
T
jkooe 689bb8c98d feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。

## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
   —— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
   volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
   PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
   (媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
   文档明示局限:游戏/部分输入型程序不响应后台键消息

## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)

## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。

## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。

## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
  新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid

## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
  按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
  `key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
  `route=post` 定向一个最小化窗口。验证通过后再发版。
2026-10-07 00:59:40 +08:00

146 lines
6.1 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 { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
parseHotkey,
normalizeKey,
expandChords,
expandPreset,
HOTKEY_PRESETS,
HOTKEY_KEYS,
NUMPAD_KEYS,
} from '../../packages/protocol/dist/index.js';
const ROOT = join(import.meta.dirname, '..', '..');
// ---------- parseHotkey ----------
test('parseHotkey:基本分隔符与大小写', () => {
assert.deepEqual(parseHotkey('ctrl+shift+esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('Ctrl+Shift+Esc'), ['ctrl', 'shift', 'esc']);
assert.deepEqual(parseHotkey('win - d'), ['win', 'd'], '分隔符支持 - 与空格');
assert.deepEqual(parseHotkey('alt + tab'), ['alt', 'tab']);
assert.deepEqual(parseHotkey('ctrl c'), ['ctrl', 'c'], '空格也可作分隔符');
});
test('parseHotkey:别名归一', () => {
assert.deepEqual(parseHotkey('command+space'), ['win', 'space']);
assert.deepEqual(parseHotkey('control+a'), ['ctrl', 'a']);
assert.deepEqual(parseHotkey('del'), ['delete']);
assert.deepEqual(parseHotkey('pgup'), ['pageup']);
assert.deepEqual(parseHotkey('numpad_enter+c'), ['enter', 'c']);
});
test('parseHotkey:符号键(快捷键覆盖的关键增量)', () => {
assert.deepEqual(parseHotkey('win+d'), ['win', 'd']);
assert.deepEqual(parseHotkey('win+oem_comma'), ['win', 'oem_comma']);
assert.deepEqual(parseHotkey('ctrl+oem_plus'), ['ctrl', 'oem_plus']);
assert.deepEqual(parseHotkey('ctrl+oem_minus'), ['ctrl', 'oem_minus']);
assert.deepEqual(parseHotkey('ctrl+0'), ['ctrl', '0']);
});
test('parseHotkey:小键盘与左右修饰键', () => {
assert.deepEqual(parseHotkey('numpad5'), ['numpad5']);
assert.deepEqual(parseHotkey('lalt+tab'), ['lalt', 'tab']);
assert.deepEqual(parseHotkey('rwin+d'), ['rwin', 'd']);
});
test('parseHotkey:非法输入必须报错(不静默吞掉)', () => {
assert.throws(() => parseHotkey(''), /空热键|不支持/);
assert.throws(() => parseHotkey('ctrl+nope'), /不支持的按键: nope/);
assert.throws(() => parseHotkey('a+b+c+d+e'), /最多 4 个键/, '单和弦超限必须拒');
});
// ---------- expandChords ----------
test('expandChords:hotkey + repeat 连按', () => {
const r = expandChords({ hotkey: 'up', repeat: 3 });
assert.deepEqual(r.chords, [['up'], ['up'], ['up']]);
assert.equal(r.via, 'hotkey');
});
test('expandChords:hotkeys 序列与 presets 序列', () => {
assert.deepEqual(expandChords({ hotkeys: ['ctrl+c', 'ctrl+v'] }).chords, [['ctrl', 'c'], ['ctrl', 'v']]);
const p = expandChords({ presets: ['copy', 'paste'] });
assert.deepEqual(p.chords, [['ctrl', 'c'], ['ctrl', 'v']]);
assert.deepEqual(p.presets, ['copy', 'paste']);
assert.equal(p.via, 'presets');
});
test('expandChords:老形式 keys/sequence 仍然可用(向后兼容)', () => {
assert.deepEqual(expandChords({ keys: ['ctrl', 'shift', 'esc'] }).chords, [['ctrl', 'shift', 'esc']]);
assert.deepEqual(expandChords({ sequence: [['up'], ['enter']] }).chords, [['up'], ['enter']]);
assert.equal(expandChords({ keys: ['ctrl'], repeat: 2 }).chords.length, 2);
});
test('expandChords:参数混用必须报错(消除"以谁为准"歧义)', () => {
assert.throws(() => expandChords({ hotkey: 'ctrl+a', keys: ['ctrl', 'a'] }), /混用/);
assert.throws(() => expandChords({}), /需提供/);
});
test('expandChords:同一和弦重复修饰键必须报错', () => {
assert.throws(() => expandChords({ keys: ['ctrl', 'ctrl', 'a'] }), /重复的修饰键/);
});
// ---------- 预设表 ----------
test('HOTKEY_PRESETS:每个预设都可展开且合法', () => {
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
const chord = parseHotkey(spec);
assert.ok(chord.length >= 1 && chord.length <= 4, `${name} → ${spec} 键数越界`);
assert.deepEqual(expandPreset(name), chord, `${name} 展开不一致`);
}
});
test('HOTKEY_PRESETS:媒体类预设映射到媒体键(可走 APPCOMMAND 通道)', () => {
assert.deepEqual(expandPreset('music_play_pause'), ['media_play_pause']);
assert.deepEqual(expandPreset('volume_mute_toggle'), ['volume_mute']);
assert.deepEqual(expandPreset('volume_add'), ['volume_up']);
});
test('HOTKEY_PRESETS:符号键类预设可展开(证明 oem 链路可用)', () => {
assert.deepEqual(expandPreset('show_desktop'), ['win', 'd']);
assert.deepEqual(expandPreset('emoji_picker'), ['win', 'oem_period']);
assert.deepEqual(expandPreset('zoom_in'), ['ctrl', 'oem_plus']);
});
test('expandPreset:未知预设必须报错', () => {
assert.throws(() => expandPreset('nope'), /未知预设/);
});
// ---------- 覆盖断言:protocol 声明的键 agent 必须能映射 ----------
test('覆盖断言:HOTKEY_KEYS + NUMPAD_KEYS 全部存在于 agent 侧 VK_MAP', () => {
const src = readFileSync(join(ROOT, 'apps/agent/src/capabilities/input-script.ts'), 'utf8');
// 抓 VK_MAP 对象体的键名(到 "媒体键名 → APPCOMMAND" 注释前为止)
const m = /const VK_MAP: Record<string, number> = \{([\s\S]*?)\n\};/.exec(src);
assert.ok(m, '未找到 VK_MAP 定义');
const declared = new Set(
[...String(m[1]).matchAll(/(?:^|[\s,])([a-z_][a-z0-9_]*)\s*:/gm)].map((x) => String(x[1])),
);
const missing = [];
for (const k of HOTKEY_KEYS) if (!declared.has(k)) missing.push(k);
for (const k of NUMPAD_KEYS) if (!declared.has(k)) missing.push(`numpad:${k}`);
assert.deepEqual(missing, [], `protocol 声明的按键在 agent 侧未映射: ${missing.join(', ')}`);
});
test('覆盖断言:每个预设只用已知键(防止表与实现漂移)', () => {
const known = new Set([...HOTKEY_KEYS, ...NUMPAD_KEYS]);
const bad = [];
for (const [name, spec] of Object.entries(HOTKEY_PRESETS)) {
for (const k of parseHotkey(spec)) {
if (!known.has(k) && !/^[a-z0-9]$/.test(k)) bad.push(`${name}→${k}`);
}
}
assert.deepEqual(bad, []);
});
test('normalizeKey:单键归一化', () => {
assert.equal(normalizeKey('CTRL'), 'ctrl');
assert.equal(normalizeKey(' command '), 'win');
assert.equal(normalizeKey('F12'), 'f12');
assert.equal(normalizeKey('a'), 'a');
assert.throws(() => normalizeKey(''), /空按键名/);
});