## 背景
原 `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` 定向一个最小化窗口。验证通过后再发版。
146 lines
6.1 KiB
JavaScript
146 lines
6.1 KiB
JavaScript
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(''), /空按键名/);
|
||
});
|